17 Commits
Author SHA1 Message Date
jetthuangai f541a2fc8c Bump version 1.10.0 2026-11-12 15:14:47 +07:00
jetthuangai be1ffd431c test: a backslash path is a Windows-only way out of the output folder 2026-11-12 14:52:00 +07:00
jetthuangai 5b3eb0d2c0 docs: folders outside ComfyUI, nh_image_folders and the 1.10.0 changelog 2026-11-12 14:52:00 +07:00
jetthuangai ce7d20df86 chore: English for the Qwen-Image note and for the comments in the node files 2026-11-12 14:52:00 +07:00
jetthuangai a2bcc41bcf fix: image nodes and the count route stay inside ComfyUI's folders and the listed ones
Save Image Path, Load Images Folder, Load images matching and
GET /nh-nodes/load-images-folder/count used any absolute path. They now read
only in ComfyUI's input, output and temp folders and write only in its output
folder, plus the folders listed under nh_image_folders in
extra_model_paths.yaml. A path is refused before the disk is touched, links
that lead out are refused or skipped, and the source path of Load images
matching is read as text only.

Image Label opens only fonts of its own list and reads the font folder when
the node list is built, not at import. The bundled DWPose code no longer sets
KMP_DUPLICATE_LIB_OK for the whole process.
2026-11-12 14:52:00 +07:00
jetthuangai 7b60ad5bea chore: leave tests and docs build tools out of the Registry package
The Comfy Registry's scanner reads the test suite's stubs and helper processes as risky
code; installed users need neither the tests nor the scripts that build the docs' images,
cards and PDFs. Both stay in the GitHub repository.
2026-10-19 10:06:34 +07:00
jetthuangai 57e2476fb5 Bump version 1.9.0 2026-10-18 22:43:01 +07:00
jetthuangai 12519a4447 chore: keep ruff off the vendored Sapiens2 file from inside it
A file-level noqa replaces the pyproject.toml exclude, so merging this branch leaves
pyproject.toml (and the Registry publish) to the 1.9.0 version bump.
2026-10-18 22:42:47 +07:00
jetthuangai c1ad6b7a14 chore: leave pyproject.toml to the version bump
The description that mentions body masks goes in with the 1.9.0 bump, so merging
this branch does not start a Registry publish of 1.8.0.
2026-10-18 22:42:47 +07:00
jetthuangai 3f37f2da3f fix: Compose advice counts only carried labels, accents in one form, exact Body words
- Segs Compose: the "join them with +" advice counts only labels that instances
  carry, so "da mat"/"face skin" on Body segs no longer stops the run with advice
  that would give the whole skin; the empty-label check reads Vietnamese words the
  way the formula does
- Labels compare accents in one form (NFC), so Vietnamese typed with combining
  accents finds the same instances
- Compose reads Body's exact words upper clothes, lower clothes and apparel
- Tests: Portrait "face skin", one report line for "arm, arms", accent forms, and a
  real-weights check that Body Segment finds nobody on pictures without people
- Docs: the Compose word table and the mirror overlap of edited and small people
2026-10-18 22:42:47 +07:00
jetthuangai e3c68d48ef docs: Body Segment, Garment on FASHN, segs mixing and licences for 1.9.0
- vision guide (EN/VI): Body Segment section, Garment model and parts, segs outputs,
  Segs Compose segs_2/segs_3, check_same_photo and node-by-node words, the nobody
  rule, the 128 px crop rule
- licences: Garment Segment is non-commercial (NVIDIA SegFormer licence: research
  or evaluation only); Body Segment under the Sapiens2 License with its prohibited
  uses; bundled Sapiens2 code and downloaded weights listed
- README and docs README: 76 nodes; CHANGELOG 1.9.0; node cards
2026-10-18 22:42:47 +07:00
jetthuangai 5e000c56ff fix: mirror check for nobody, Compose reads each node on its own, plural exceptions
- Body Segment: with no face found, a picture is empty when Sapiens2 is unsure of
  most of what it labels or a look at the mirrored picture does not find the same
  people (everyday edits of a facade kept fooling the sure-share rule alone)
- Segs Compose and Segs Select read each node's segs on their own; a node's words
  stand for several of its parts only when they are group words (Body: skin, head,
  clothes, person...; Garment: its part groups; Portrait: face, face skin); a part
  keeps its own name; Vietnamese tay/chan are whole limbs there too
- Plurals: glasses is not glass; -ves and -ies words fold; SAM3 Segment searches
  "arm, arms" once
- Body Segment: beard advice, class notes only for regions in the picture; the
  Garment model tooltip drops an unverified year
- Tests guard the share rule, the mirror rule, node-by-node reading, group words
  and the whole limbs on a picture with hands and feet
2026-10-18 22:42:47 +07:00
jetthuangai 0a56ae8372 fix: Compose reads each node's words, robust no-people rule, small crops left alone
- Segs Compose and Segs Select match singular or plural, and read each node's own
  words on its segs ("skin" on Body Segment segs, "upper garment" on Garment segs,
  "face" on Portrait segs); Compose gets check_same_photo to mix edits of one photo
- Body Segment: a picture is empty when no face is found and Sapiens2 is sure of less
  than half of what it labels (the median rule depended on the JPEG decoder); the
  report says "no person found"; region words in singular or plural; Vietnamese
  tay/chan forms (doi tay, tay trai, chan phai...) kept apart from canh tay;
  more limb words (bap tay, cang tay, dui, bap chan, cang chan)
- Parsers: a region shorter than 128 px is not parsed again (a tiny crop blown up to
  the model's input came back as speckles)
- Picture prints ignore NaN pixels; images in half precision or bfloat16 are read
- Docs examples: Body Segment panel; examples that use a garment mask follow fashn
2026-10-18 22:42:47 +07:00
jetthuangai 67b0d14b2d fix: Body Segment words and preview, nobody on empty pictures, Compose refuses other photos
- Body Segment: show_preview is optional; a word it does not know is refused before
  the model loads, with what to write instead; Vietnamese tay/chan include the hands
  and feet; more words (t-shirt, jacket, sunglasses, head...); the report explains
  left/right, face with neck, dress as top; the preview shows only the parts the mask
  keeps; a picture without faces that Sapiens2 is unsure of has nobody in it
- Sapiens2: the weights file is no longer kept mapped, one size stays in memory,
  a damaged file says to delete it, PyTorch 2.4 is checked, the CPU note shows once
- Garment Segment: fashn all_garments includes footwear; FASHN pictures are resized
  as it was trained; licence and fill_holes tooltips; build_mask defaults to fashn
- Segs: frames keep a small print of their picture, so Segs Compose refuses segs of
  different photos of the same size
- parts_frame uses a lookup table
- Docs example: Garment panel shows fashn, attach_parts with fashionpedia
2026-10-18 22:42:47 +07:00
jetthuangai 1101bd91b6 feat: Garment Segment on FASHN, new Body Segment (Sapiens2), parsers output segs
Vision wave B (1.9.0):
- Garment Segment: optional model input, fashn (FASHN Human Parser,
  SegFormer-B4, pinned) by default, fashionpedia kept; new parts dress,
  bag, belt, scarf, glasses, jewelry; segs output with every part found.
- Body Segment (new node, 76 in total): Sapiens2 body-part segmentation
  (Meta, 0.4b/0.8b/1b, pinned, standalone model code vendored with its
  licence in core/sapiens2), regions typed as a formula (skin - lips,
  hair + face, teeth; Vietnamese words), people parsed close up on 3:4
  crops, on-node preview, report and segs outputs.
- Portrait Segment outputs segs too; Segs Compose mixes up to three segs.
- segs and the formula frame are built from the 4096 px working map, so
  they cost little on 8K photos.
2026-10-18 22:42:47 +07:00
jetthuangai 51822fe912 Bump version 1.8.0 2026-10-18 18:17:09 +07:00
jetthuangai ee93e7133c feat: masks from one SAM3 Segment node, faces and clothes parsed close up
Vision wave A (1.8.0):
- SAM3 Segment takes a mask formula in concepts (commas/new lines list,
  + - & ~ parentheses, words in a row are one label, background keyword),
  translates common Vietnamese words, adds a report output and an optional
  on-node preview (mask bright, instances labelled, report below), and is an
  output node. Masks of things running off the picture reach its edge.
- Segs Compose shares the formula language; Segs Select reads Vietnamese.
- Portrait Segment: whole-frame parse refined per face (YuNet, MIT, pinned,
  two sizes plus a second look with a margin); faces confirmed by the parser;
  wide crop for hair/neck, tight crop for features inside an ellipse.
- Garment Segment: whole-frame parse refined per group of people (crops of
  the model's shape, grown when people run past an edge, merged by
  confidence, only parts anchored on people already found).
- Parsers work on at most 4096 px and pre-shrink big inputs.
- Docs EN/VI, changelog, notices (YuNet), node card and examples.
2026-10-18 18:16:56 +07:00
76 changed files with 6985 additions and 727 deletions
+5
View File
@@ -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/
+39 -14
View File
@@ -11,10 +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, find every person, window or sky by name with SAM 3, 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).
@@ -32,9 +34,11 @@ Every node is documented with a visual card, its inputs and outputs, and example
</tr>
<tr>
<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) and SAM 3 returns every instance as its own
mask, with crisp edges on small and medium objects even in 4K–8K photos; garment and face-part masks from
SegFormer models; VTON garment masks with pose and parsing.</td>
<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; 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>
@@ -67,24 +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). 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)
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
@@ -97,7 +115,8 @@ 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 every person, window or the sky in a large photo | Load Image → **SAM3 Segment** → **Segs Select** or **Segs Compose** → mask → edit or inpaint | [vision](docs/en/vision.md#sam-3-workflows) |
| 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) |
@@ -111,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) |
@@ -135,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.
+89 -4
View File
@@ -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,7 +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) |
| 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, 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
+118
View File
@@ -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()
+108
View File
@@ -0,0 +1,108 @@
"""Face boxes for Portrait Segment, from YuNet (OpenCV Zoo, MIT license).
YuNet is a 230 KB ONNX model that runs on the CPU through the OpenCV the pack already needs, so finding faces costs
no VRAM and no extra package. The weights are fetched once, pinned to a fixed Hugging Face revision, into the same
``nh_nodes`` model folder as the parsers (models/NH-Nodes/yunet by default).
"""
import os
import cv2
import numpy as np
from .model_files import download_pinned, register_model_folder
from .segformer_backend import FOLDER_EXTENSIONS, FOLDER_NAME, default_folder
YUNET = {
"repo_id": "opencv/face_detection_yunet",
"revision": "3cc26e7f1014a5ee5d74a42acee58bafc9d0a310",
"file": "face_detection_yunet_2023mar.onnx",
"folder": "yunet",
}
MIN_SCORE = 0.6 # YuNet's own samples use 0.9; a box that is not a face is turned down later by the face parser
DETECT_SIDES = (2048, 640) # faces are looked for on copies at most this long: small faces show on the first, big or
# grainy ones (scanned prints, close-ups) are often found only on the second
MAX_FACES = 32 # the largest ones; each face costs two parses
_detector = None
def model_path(download=True):
"""Path of the YuNet weights, from any registered ``nh_nodes`` folder; downloaded into the default one if absent."""
for base in register_model_folder(FOLDER_NAME, default_folder(), FOLDER_EXTENSIONS):
candidate = os.path.join(base, YUNET["folder"], YUNET["file"])
if os.path.isfile(candidate):
return candidate
if not download:
return None
target = os.path.join(default_folder(), YUNET["folder"])
download_pinned(YUNET["repo_id"], YUNET["revision"], target, (YUNET["file"], "LICENSE"))
path = os.path.join(target, YUNET["file"])
if not os.path.isfile(path):
raise RuntimeError(f"[NH-Nodes] Face detector missing after download: {path}")
return path
def _get_detector(size):
"""The YuNet detector, set up for pictures of ``size`` (width, height). Loaded from bytes read by Python, so a
model folder whose path has accents (which OpenCV cannot open on Windows) works too."""
global _detector
if _detector is None:
weights = np.fromfile(model_path(), dtype=np.uint8)
_detector = cv2.FaceDetectorYN.create("onnx", weights, np.empty(0, np.uint8), size, MIN_SCORE, 0.3, 5000)
_detector.setInputSize(size)
return _detector
def _detect_at(image, max_side, min_score):
"""Face boxes found on a copy of ``image`` at most ``max_side`` pixels long, in the image's pixels."""
height, width = image.shape[:2]
scale = min(1.0, max_side / max(height, width))
small = image
if scale < 1.0:
small = cv2.resize(image, (max(1, round(width * scale)), max(1, round(height * scale))),
interpolation=cv2.INTER_AREA)
detector = _get_detector((small.shape[1], small.shape[0]))
_, found = detector.detect(cv2.cvtColor(np.ascontiguousarray(small), cv2.COLOR_RGB2BGR))
faces = []
for row in [] if found is None else found:
score = float(row[-1])
if score < min_score:
continue
x, y, w, h = (float(v) / scale for v in row[:4])
box = (max(0.0, x), max(0.0, y), min(float(width), x + w), min(float(height), y + h))
if box[2] > box[0] and box[3] > box[1]:
faces.append((*box, score))
return faces
def _area(box):
return max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1])
def _overlap(a, b):
return _area((max(a[0], b[0]), max(a[1], b[1]), min(a[2], b[2]), min(a[3], b[3])))
def merge_faces(faces):
"""One box per face from boxes found at several scales: best score first, a box is dropped when it overlaps a
kept one (IoU 0.3 or more) or lies mostly (60%) inside one, as a half face found at one scale does."""
kept = []
for face in sorted(faces, key=lambda f: f[4], reverse=True):
if all(_overlap(face, other) < 0.3 * (_area(face) + _area(other) - _overlap(face, other))
and _overlap(face, other) < 0.6 * min(_area(face), _area(other)) for other in kept):
kept.append(face)
return kept
def detect_faces(image, min_score=MIN_SCORE, sides=DETECT_SIDES, limit=MAX_FACES):
"""[(x1, y1, x2, y2, score)] of the faces in the uint8 RGB ``image``, in its pixels, largest first.
Looks on copies of each length in ``sides`` and merges what they find (see merge_faces), which finds faces from
about 1/150 of the picture's long side up to close-ups. A face cut by the frame is often missed; the caller can
look again on a copy with a margin, and parses the whole frame when nothing is found.
"""
found = [face for side in sides for face in _detect_at(image, side, min_score)]
faces = merge_faces(found)
faces.sort(key=_area, reverse=True)
return faces[:limit]
+4
View File
@@ -12,6 +12,7 @@ import os
from aiohttp import web
from .allowed_folders import FolderNotAllowed
from .image_folders import collect_image_files, resolve_load_dir
COUNT_PATH = "/nh-nodes/load-images-folder/count"
@@ -51,6 +52,9 @@ async def nh_load_images_folder_count(request):
count = await asyncio.to_thread(_count_images, folder_path, recursive)
except FileNotFoundError:
return _error(404, "folder not found")
except FolderNotAllowed:
# Outside ComfyUI's own folders and the folders listed under nh_image_folders: refused before the disk is read.
return _error(403, "folder is not allowed")
except PermissionError:
return _error(403, "folder is not readable")
except Exception as exc:
+12 -3
View File
@@ -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):
+360
View File
@@ -0,0 +1,360 @@
"""Where to run a parsing model on a big picture, and how to put its answers back (model-free, unit tested).
Parsing models see a small fixed-size picture (512 x 512 for the SegFormer face and fashion parsers). Squeezing a
whole 8K photo into that leaves a face a few dozen pixels wide, so the parsers also run on crops: a square around
each face, or a box of the model's shape around each group of people. A crop only refines the parse of the whole
frame: it is pasted inside its own region, away from the region's cut edges, so whatever it misses keeps the
whole-frame answer and no straight crop edge shows. Regions are (x1, y1, x2, y2) in image pixels with exclusive ends,
and may reach past the image; the missing part is padded with grey.
"""
import cv2
import numpy as np
PAD_VALUE = 114 # grey, for the parts of a crop outside the picture
def square_around(box, scale, lift=0.0):
"""Square region ``scale`` times the longer side of ``box``, centred on it and moved up by ``lift`` times its
side (a head's hair sits above its face box)."""
x1, y1, x2, y2 = box[:4]
side = max(1.0, scale * max(x2 - x1, y2 - y1))
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0 - lift * side
left, top = int(round(cx - side / 2.0)), int(round(cy - side / 2.0))
size = int(round(side))
return left, top, left + size, top + size
def _span(lo, hi, size, limit):
"""[start, start + size) holding [lo, hi), slid to stay inside [0, limit) when it fits there."""
start = (lo + hi) / 2.0 - size / 2.0
if size <= limit:
start = min(max(0.0, start), limit - size)
start = int(round(start))
return start, start + int(size)
def fit_box(box, aspect, margin, image_size):
"""Region of the model's shape (``aspect`` = width / height) around ``box`` plus ``margin`` (a fraction of its
longer side) on each side. It stays inside the image where it fits there, and is padded where it does not."""
height, width = image_size
x1, y1, x2, y2 = box[:4]
pad = margin * max(x2 - x1, y2 - y1)
w, h = (x2 - x1) + 2 * pad, (y2 - y1) + 2 * pad
w, h = max(w, h * aspect), max(h, w / aspect)
w, h = int(np.ceil(w)), int(np.ceil(h))
left, right = _span(x1, x2, w, width)
top, bottom = _span(y1, y2, h, height)
return left, top, right, bottom
SPLIT = 1.3 # a box this much wider than the parser's shape is covered by a row of regions instead of one
def _centres(lo, hi, tile, overlap):
count = max(2, int(np.ceil((hi - lo - tile) / (tile * (1.0 - overlap)))) + 1)
return np.linspace(lo + tile / 2.0, hi - tile / 2.0, count)
def cover_box(box, aspect, margin, image_size, overlap=0.2):
"""Regions of the parser's shape (``aspect`` = width / height) that together cover ``box`` plus ``margin``.
One region (see fit_box) unless the box is much wider than that shape, as a group photo is: then a row of
overlapping regions as tall as the box (plus a margin of its height), so each person keeps the parser's resolution
instead of the group being squeezed into one region with grey bands above and below. A tall box keeps one region:
splitting a standing person into halves would hide the rest of the body from the parser.
"""
x1, y1, x2, y2 = box[:4]
pad = margin * (y2 - y1)
bx1, by1, bx2, by2 = x1 - pad, y1 - pad, x2 + pad, y2 + pad
if bx2 - bx1 <= (by2 - by1) * aspect * SPLIT:
return [fit_box(box, aspect, margin, image_size)]
tile = (by2 - by1) * aspect
return [fit_box((c - tile / 2.0, by1, c + tile / 2.0, by2), aspect, 0.0, image_size)
for c in _centres(bx1, bx2, tile, overlap)]
def square_frame(image_size):
"""The square region centred on the picture that holds all of it (the short side is padded)."""
height, width = image_size
side = max(height, width)
left, top = (width - side) // 2, (height - side) // 2
return left, top, left + side, top + side
def visible(region, image_size):
"""The part of ``region`` inside an image of ``image_size`` (height, width)."""
height, width = image_size
x1, y1, x2, y2 = region
return max(0, x1), max(0, y1), min(width, x2), min(height, y2)
def cut_padded(image, region):
"""``image[region]`` (H x W x C), padded with grey where the region leaves the image."""
height, width = image.shape[:2]
x1, y1, x2, y2 = region
vx1, vy1, vx2, vy2 = visible(region, (height, width))
crop = image[vy1:vy2, vx1:vx2]
pads = (vy1 - y1, y2 - vy2, vx1 - x1, x2 - vx2)
if any(pads):
crop = cv2.copyMakeBorder(np.ascontiguousarray(crop), *pads, cv2.BORDER_CONSTANT,
value=(PAD_VALUE,) * image.shape[2])
return np.ascontiguousarray(crop)
def nearest_owner(region, boxes, regions, index):
"""Bool mask over ``region`` (inside the image): the pixels that belong to ``boxes[index]`` because no other box
whose region also covers them is nearer (distance from the box centre, in box sizes). Where the crops around two
faces overlap, each face keeps the parse of its own crop."""
x1, y1, x2, y2 = region
overlapping = [other for other in range(len(boxes)) if other != index and regions[other][2] > x1
and regions[other][0] < x2 and regions[other][3] > y1 and regions[other][1] < y2]
if not overlapping:
return np.ones((y2 - y1, x2 - x1), dtype=bool)
# decided on a grid of at most OWNER_GRID points a side, then enlarged: the border between two faces runs through
# hair or background, where a few pixels do not matter, and an 8K crop would otherwise cost a second per face
step = max(1, int(np.ceil(max(x2 - x1, y2 - y1) / OWNER_GRID)))
ys = np.arange(y1, y2, step, dtype=np.float32)[:, None] + step / 2.0
xs = np.arange(x1, x2, step, dtype=np.float32)[None, :] + step / 2.0
def distance(box):
bx1, by1, bx2, by2 = box[:4]
size = max(1.0, float(max(bx2 - bx1, by2 - by1)))
return np.hypot(xs - (bx1 + bx2) / 2.0, ys - (by1 + by2) / 2.0) / size
own = distance(boxes[index])
keep = np.ones(own.shape, dtype=bool)
for other in overlapping:
ox1, oy1, ox2, oy2 = regions[other]
covered = (xs >= ox1) & (xs < ox2) & (ys >= oy1) & (ys < oy2)
keep &= ~covered | (own <= distance(boxes[other]))
if step > 1:
keep = cv2.resize(keep.astype(np.uint8), (x2 - x1, y2 - y1), interpolation=cv2.INTER_NEAREST) > 0
return keep
OWNER_GRID = 512
CUT_BAND = 0.08 # share of a crop's side, along each of its edges that cuts the picture, left to the whole frame
NEAR_PEOPLE = 0.02 # how far (share of the long side) from the whole-frame parse's people a crop may add labels
def inner_mask(region, image_size, band=CUT_BAND):
"""Bool mask over the part of ``region`` inside the picture: True except within ``band`` of the region's size
along its edges that lie inside the picture. The parser sees things cut there, so its answer is less sure; an edge
that is the picture's own edge (or beyond it) keeps everything."""
height, width = image_size
x1, y1, x2, y2 = region
vx1, vy1, vx2, vy2 = visible(region, image_size)
keep = np.ones((vy2 - vy1, vx2 - vx1), dtype=bool)
cut_x, cut_y = int(round(band * (x2 - x1))), int(round(band * (y2 - y1)))
if x1 > 0:
keep[:, :max(0, x1 + cut_x - vx1)] = False
if x2 < width:
keep[:, max(0, (x2 - cut_x) - vx1):] = False
if y1 > 0:
keep[:max(0, y1 + cut_y - vy1), :] = False
if y2 < height:
keep[max(0, (y2 - cut_y) - vy1):, :] = False
return keep
def parse_region(predict, image, region):
"""(``predict`` run on the padded crop, cut to the part of ``region`` inside the picture; that part). When
``predict`` returns several maps (labels and their confidence), each is cut the same way."""
seen = visible(region, image.shape[:2])
result = predict(cut_padded(image, region))
x1, y1 = region[:2]
rows, cols = slice(seen[1] - y1, seen[3] - y1), slice(seen[0] - x1, seen[2] - x1)
if isinstance(result, tuple):
return tuple(part[rows, cols] for part in result), seen
return result[rows, cols], seen
GROW_ROUNDS = 3 # times a crop may grow when its people run past one of its cut edges
GROW_STEP = 0.5 # how much it grows each time, as a share of its size
def _cut_sides_with_people(labels, region, image_size, background=0, share=0.02):
"""Edges of ``region`` that cut the picture and where the parse (of the part inside the picture) still finds
people within CUT_BAND: there the crop sliced through somebody."""
height, width = image_size
x1, y1, x2, y2 = region
vx1, vy1, vx2, vy2 = visible(region, image_size)
band_x, band_y = max(1, int(round(CUT_BAND * (x2 - x1)))), max(1, int(round(CUT_BAND * (y2 - y1))))
found = labels != background
sides = []
if x1 > 0 and found[:, :band_x].mean() > share:
sides.append("left")
if x2 < width and found[:, max(0, (vx2 - vx1) - band_x):].mean() > share:
sides.append("right")
if y1 > 0 and found[:band_y, :].mean() > share:
sides.append("top")
if y2 < height and found[max(0, (vy2 - vy1) - band_y):, :].mean() > share:
sides.append("bottom")
return sides
def _grown(region, sides, aspect, image_size):
"""``region`` enlarged by GROW_STEP of its size towards ``sides``, back in the parser's shape."""
x1, y1, x2, y2 = region
step_x, step_y = GROW_STEP * (x2 - x1), GROW_STEP * (y2 - y1)
box = (x1 - step_x * ("left" in sides), y1 - step_y * ("top" in sides),
x2 + step_x * ("right" in sides), y2 + step_y * ("bottom" in sides))
return fit_box(box, aspect, 0.0, 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):
"""Rough box of the person a face (x1, y1, x2, y2[, score]) belongs to: five face widths wide, from just above
the head to about eight face heights below its top (a standing person), inside the picture."""
height, width = image_size
x1, y1, x2, y2 = face[:4]
face_w, face_h = x2 - x1, y2 - y1
centre = (x1 + x2) / 2.0
return (max(0, int(centre - 2.5 * face_w)), max(0, int(y1 - 0.6 * face_h)),
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=(), 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).
A first look parses the whole frame and is kept as the answer to refine. Each group of people (those it found,
and the ``people`` boxes found from faces, which it may have missed) gets regions of the parser's shape (a row of
them for a wide group, see cover_box), parsed again at the parser's full resolution; a region whose people run past
one of its cut edges grows that way and is parsed again. A region's label replaces the current one where it is
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)) 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
for x1, y1, x2, y2 in people:
anchor[y1:y2, x1:x2] = True
near = near_people(anchor.astype(np.uint8))
canvas, trust = coarse.copy(), trust.astype(np.float32) * COARSE_TRUST
for region in regions:
(labels, sure), seen = parse_region(predict, image, region)
for _ in range(GROW_ROUNDS):
sides = _cut_sides_with_people(labels, region, (height, width))
if not sides:
break
region = _grown(region, sides, aspect, (height, width))
(labels, sure), seen = parse_region(predict, image, region)
if labels.size == 0:
continue
x1, y1, x2, y2 = seen
sure = sure.astype(np.float32)
allowed = anchored(labels, anchor[y1:y2, x1:x2]) | ((labels == 0) & near[y1:y2, x1:x2])
keep = inner_mask(region, (height, width)) & allowed & (sure > trust[y1:y2, x1:x2])
canvas[y1:y2, x1:x2][keep] = labels[keep]
trust[y1:y2, x1:x2][keep] = sure[keep]
return canvas
ANCHOR_SHARE = 0.1 # share of a crop's part that must lie on people already found for the part to be kept
def anchored(labels, anchor, share=ANCHOR_SHARE, grid=1024):
"""Bool mask of the labelled pixels of a crop's ``labels`` whose part (the connected pixels of one label) lies on
``anchor`` (people already found) for at least ``share`` of its area. A crop may so extend a person the first look
saw only in part, but anything it finds on its own (clothes "seen" on a plain backdrop, even right next to
somebody) is dropped. Worked out on a copy at most ``grid`` pixels long."""
height, width = labels.shape
scale = min(1.0, grid / max(height, width, 1))
small, held = labels, anchor
if scale < 1.0:
size = (max(1, round(width * scale)), max(1, round(height * scale)))
small = cv2.resize(labels.astype(np.int32), size, interpolation=cv2.INTER_NEAREST)
held = cv2.resize(anchor.astype(np.uint8), size, interpolation=cv2.INTER_NEAREST) > 0
kept = np.zeros(small.shape, dtype=bool)
for value in np.unique(small):
if value == 0:
continue
count, parts = cv2.connectedComponents((small == value).astype(np.uint8), connectivity=8)
if count <= 1:
continue
areas = np.bincount(parts.ravel(), minlength=count)
on_people = np.bincount(parts[held], minlength=count)
good = on_people >= share * areas
good[0] = False
kept |= good[parts]
if scale < 1.0:
kept = cv2.resize(kept.astype(np.uint8), (width, height), interpolation=cv2.INTER_NEAREST) > 0
return kept & (labels != 0)
def near_people(labels, background=0, reach=NEAR_PEOPLE, grid=1024):
"""Bool map of the pixels within ``reach`` (a share of the long side) of a labelled pixel. Worked out on a copy at
most ``grid`` pixels long and enlarged, since a few pixels do not matter here and a 300 px dilation of an 8K map
would take seconds."""
height, width = labels.shape
scale = min(1.0, grid / max(height, width))
found = (labels != background).astype(np.uint8)
if scale < 1.0:
found = cv2.resize(found, (max(1, round(width * scale)), max(1, round(height * scale))),
interpolation=cv2.INTER_AREA)
found = (found > 0).astype(np.uint8)
radius = max(1, int(round(reach * max(found.shape))))
grown = cv2.dilate(found, cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * radius + 1, 2 * radius + 1)))
if scale < 1.0:
grown = cv2.resize(grown, (width, height), interpolation=cv2.INTER_NEAREST)
return grown > 0
def foreground_clusters(labels, background=0, min_fraction=0.001, gap=0.05, extra=()):
"""Boxes around the groups of labelled pixels of a label map (and the ``extra`` boxes): specks under
``min_fraction`` of the picture are left out, and boxes closer to each other than ``gap`` of the picture's long
side are merged, so people standing together form one group and people far apart get one box each."""
found = (labels != background).astype(np.uint8)
count, _, stats, _ = cv2.connectedComponentsWithStats(found, connectivity=8)
boxes = [[int(stats[i, cv2.CC_STAT_LEFT]), int(stats[i, cv2.CC_STAT_TOP]),
int(stats[i, cv2.CC_STAT_LEFT] + stats[i, cv2.CC_STAT_WIDTH]),
int(stats[i, cv2.CC_STAT_TOP] + stats[i, cv2.CC_STAT_HEIGHT])]
for i in range(1, count) if stats[i, cv2.CC_STAT_AREA] >= min_fraction * labels.size]
boxes += [[int(v) for v in box[:4]] for box in extra]
reach = gap * max(labels.shape)
merged = True
while merged:
merged = False
for i in range(len(boxes)):
for j in range(i + 1, len(boxes)):
a, b = boxes[i], boxes[j]
if (max(a[0], b[0]) - min(a[2], b[2]) < reach) and (max(a[1], b[1]) - min(a[3], b[3]) < reach):
boxes[i] = [min(a[0], b[0]), min(a[1], b[1]), max(a[2], b[2]), max(a[3], b[3])]
del boxes[j]
merged = True
break
if merged:
break
return [tuple(box) for box in sorted(boxes)]
+1 -1
View File
@@ -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",
+76 -12
View File
@@ -77,6 +77,17 @@ _CROP_MARGIN = 0.2 # pass-2 crop margin on each side, as a fraction of the inst
_CROP_MIN_MARGIN = 16 # px
_CROP_MAX_ASPECT = 2.0 # pass-2 crops are widened to at most 2:1, the model squeezes them into a square
_SAMPLE_CHUNK = 1 << 22 # output pixels per grid_sample call when resampling masks
# SAM 3's masks fade out along the border of the square view it segments: on a 1008 px view the last 3 rows and
# columns hold logits near 0 even where the object runs off the picture (79% / 23% / 23% of an edge's object pixels
# still positive), which left a ragged gap along the image edge. That band is lifted to the first row inside it, only
# where it is still in the fade (see extend_to_border). A close-up fades over 2 to 3 rows of its own view. The price:
# a thing that ends within that band (about 0.3% of the picture's side) above the edge is drawn down to the edge,
# which costs far less than the ragged edge every person cut by the frame used to get.
_BORDER_CELLS = 3
_CLOSE_UP_CELLS = 3
_FADE_FLOOR = -2.5 # band logits below this are a sure "no" and are left alone (the fade reaches about -1.6)
_VIEW_SIZE = 1008 # side of SAM 3's square view
NEAR_MISS = 0.1 # candidates scoring at least this (but under the threshold) are reported as near misses
_WORKING_BYTES = 3 << 30 # VRAM for activations and the full-resolution image, on top of the weights
_API = None
@@ -416,6 +427,38 @@ def sample_region(logits, image_size, region, out_size):
return result
def _lift(band, inner):
"""``band`` raised to ``inner`` where that is higher and ``band`` is still in the fade (above _FADE_FLOOR)."""
if torch.is_tensor(band):
return torch.where(band > _FADE_FLOOR, torch.maximum(band, inner), band)
return np.where(band > _FADE_FLOOR, np.maximum(band, inner), band)
def extend_to_border(logits, band_y, band_x, edges=("top", "bottom", "left", "right")):
"""Copy of ``logits`` [..., H, W] (torch or numpy) whose outer ``band_y`` rows / ``band_x`` columns on the given
``edges`` are lifted to the first row / column inside the band, where they are still in the fade. A mask can only
grow there, and only where the object reaches the band from inside."""
out = logits.clone() if torch.is_tensor(logits) else np.array(logits, copy=True)
height, width = out.shape[-2:]
band_y, band_x = min(int(band_y), (height - 1) // 2), min(int(band_x), (width - 1) // 2)
if band_y > 0 and "top" in edges:
out[..., :band_y, :] = _lift(out[..., :band_y, :], out[..., band_y:band_y + 1, :])
if band_y > 0 and "bottom" in edges:
out[..., height - band_y:, :] = _lift(out[..., height - band_y:, :],
out[..., height - band_y - 1:height - band_y, :])
if band_x > 0 and "left" in edges:
out[..., :, :band_x] = _lift(out[..., :, :band_x], out[..., :, band_x:band_x + 1])
if band_x > 0 and "right" in edges:
out[..., :, width - band_x:] = _lift(out[..., :, width - band_x:], out[..., :, width - band_x - 1:width - band_x])
return out
def border_band(pixels, view=_VIEW_SIZE, cells=_BORDER_CELLS):
"""Width in pixels of the faded band (``cells`` rows of SAM 3's view) along an edge ``pixels`` long that SAM 3
saw ``view`` pixels long."""
return math.ceil(cells * pixels / view)
def mask_box(mask):
"""Tight (x1, y1, x2, y2) box of a bool [H, W] tensor with exclusive ends; None when empty."""
rows = torch.nonzero(mask.any(dim=1)).flatten()
@@ -492,17 +535,28 @@ def _box_overlap(a, b):
return (x1, y1, x2, y2) if x1 < x2 and y1 < y2 else None
def find_instances(processor, state, concept, max_instances=0):
"""[(score, logits [S, S], model-space box)] for one concept on an encoded image, best first, duplicates dropped."""
def find_instances(processor, state, concept, max_instances=0, threshold=0.0, misses=None):
"""[(score, logits [S, S], model-space box)] for one concept on an encoded image, best first, duplicates dropped.
Candidates scoring under ``threshold`` are left out; the best of them goes to ``misses[concept]`` when a dict is
given, so the node can say how close a concept it did not find came. The logits are extended to the border of
the view first (SAM 3's masks fade out there, see extend_to_border).
"""
processor.reset_all_prompts(state)
output = processor.set_text_prompt(concept, state)
masks, scores = output["masks_logits"], output["scores"]
if masks is None or len(scores) == 0:
return []
scores = scores.float().flatten()
under = scores <= threshold # SAM 3 itself keeps only scores above the threshold
if misses is not None and bool(under.any()):
misses[concept] = max(misses.get(concept, 0.0), float(scores[under].max()))
band = border_band(masks.shape[-1])
found, kept = [], [] # kept: (bool mask, box, area) of accepted instances
for i in torch.argsort(scores, descending=True).tolist():
logits = to_logits(masks[i].reshape(masks.shape[-2], masks.shape[-1]))
for i in torch.argsort(scores, descending=True, stable=True).tolist():
if float(scores[i]) <= threshold:
break
logits = extend_to_border(to_logits(masks[i].reshape(masks.shape[-2], masks.shape[-1])), band, band)
inside = logits > 0
box = mask_box(inside)
if box is None:
@@ -560,7 +614,14 @@ def _refine(model, processor, image, concept, score, logits, box, image_size, hi
prompt_box = np.array([x1 - cx1, y1 - cy1, x2 - cx1, y2 - cy1], dtype=np.float32)
masks, _, _ = model.predict_inst(state, box=prompt_box, mask_input=hint[None], multimask_output=False,
return_logits=True)
refined = np.asarray(masks).reshape(-1, cy2 - cy1, cx2 - cx1)[0] > 0
logits = np.asarray(masks, dtype=np.float32).reshape(-1, cy2 - cy1, cx2 - cx1)[0]
# the close-up fades out along its own border too, which matters where that border is the image's
height, width = image_size
edges = [edge for edge, at_image_edge in (("top", cy1 == 0), ("bottom", cy2 == height), ("left", cx1 == 0),
("right", cx2 == width)) if at_image_edge]
view = int(getattr(processor, "resolution", _VIEW_SIZE))
refined = extend_to_border(logits, border_band(cy2 - cy1, view, _CLOSE_UP_CELLS),
border_band(cx2 - cx1, view, _CLOSE_UP_CELLS), edges) > 0
refined = drop_stray_parts(refined, coarse)
accepted = close_up_agrees(refined, coarse, max(image_size) / _PASS1_GRID + 2.0)
return make_segment(concept, score, refined if accepted else coarse, origin=(cx1, cy1)), accepted
@@ -577,12 +638,14 @@ def _image_chw(image):
def segment_images(model, make_processor, images, concepts, threshold=0.5, detail_level="balanced",
max_per_concept=0, device=None, interrupt=None, progress=None):
max_per_concept=0, device=None, interrupt=None, progress=None, misses=None):
"""NH_SEGS (one SegsFrame per image) for ``concepts`` found in the ComfyUI IMAGE batch ``images``.
``make_processor(threshold)`` returns a Sam3Processor for ``model`` on ``device``; the model must already be
there. Instances come in concept order, best score first within a concept. ``interrupt()`` runs before each
concept and each close-up (it raises to stop), ``progress(done, total)`` after each concept of each image.
there. It is asked for candidates down to NEAR_MISS, so that ``misses`` (a dict, when given) learns the best score
under ``threshold`` of each concept. Instances come in concept order, best score first within a concept.
``interrupt()`` runs before each concept and each close-up (it raises to stop), ``progress(done, total)`` after
each concept of each image.
"""
device = device or _model_device(model)
refine = detail_level != "fast"
@@ -597,7 +660,7 @@ def segment_images(model, make_processor, images, concepts, threshold=0.5, detai
print(f"[NH-Nodes] SAM 3 sees the whole {width}x{height} picture squeezed into a square, so on images "
"wider than 4:1 it can miss small things; split such a picture into tiles for better results")
pixels = _image_chw(image).to(device)
processor = make_processor(threshold)
processor = make_processor(min(threshold, NEAR_MISS))
size = int(getattr(processor, "resolution", 1008))
square = F.interpolate(pixels[None], size=(size, size), mode="bilinear", antialias=True,
align_corners=False)[0].clamp_(0.0, 1.0)
@@ -606,7 +669,8 @@ def segment_images(model, make_processor, images, concepts, threshold=0.5, detai
for concept in concepts:
if interrupt is not None:
interrupt()
for score, logits, box in find_instances(processor, state, concept, max_per_concept):
for score, logits, box in find_instances(processor, state, concept, max_per_concept, threshold,
misses):
if refine:
if interrupt is not None:
interrupt()
@@ -628,7 +692,7 @@ def segment_images(model, make_processor, images, concepts, threshold=0.5, detai
def segment(images, concepts, threshold=0.5, detail_level="balanced", max_per_concept=0, checkpoint=DEFAULT_CHECKPOINT,
keep_model_loaded=False, interrupt=None, progress=None):
keep_model_loaded=False, interrupt=None, progress=None, misses=None):
"""Load SAM 3 and run ``segment_images`` on ComfyUI's GPU; the weights go back to RAM afterwards unless kept."""
if images.shape[0] == 0:
raise ValueError("[NH-Nodes] SAM 3 got an empty image batch")
@@ -639,7 +703,7 @@ def segment(images, concepts, threshold=0.5, detail_level="balanced", max_per_co
move_model(model, device)
return segment_images(
model, lambda value: processor_cls(model, device=device, confidence_threshold=value), images, concepts,
threshold, detail_level, max_per_concept, device, interrupt, progress,
threshold, detail_level, max_per_concept, device, interrupt, progress, misses,
)
finally:
if not keep_model_loaded:
+79
View File
@@ -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.
+1
View File
@@ -0,0 +1 @@
"""Meta Sapiens2 model code, vendored (see LICENSE.md here and backbone.py)."""
+915
View File
@@ -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
+211
View File
@@ -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)
+31 -4
View File
@@ -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
@@ -170,20 +178,39 @@ def offload(bundle):
torch.cuda.empty_cache()
def predict_labels(bundle, image_uint8):
"""Run SegFormer on one HxWx3 uint8 RGB array -> HxW int64 label map at input size."""
def predict_labels(bundle, image_uint8, confidence=False):
"""Run SegFormer on one HxWx3 uint8 RGB array -> HxW int64 label map at input size.
With ``confidence`` it returns (labels, HxW float16 probability of each pixel's label), which lets crops of the
same picture be merged by how sure each one is (see parse_crops.people_labels).
"""
_ensure_on_device(bundle)
processor, model = bundle["processor"], bundle["model"]
height, width = image_uint8.shape[:2]
scale = min(1.0, _MAX_LOGIT_SIDE / max(height, width))
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. 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:
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():
logits = model(**inputs).logits
logits = F.interpolate(logits, size=logit_size, mode="bilinear", align_corners=False)
labels = logits.argmax(dim=1)[0].to(torch.int32).cpu().numpy()
best, labels = logits.float().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)
+384 -43
View File
@@ -9,14 +9,18 @@ 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
import torch
import torch.nn.functional as F
from PIL import Image, ImageDraw, ImageFont
from .vi_words import translate
SEGS_TYPE = "NH_SEGS"
@@ -47,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."""
@@ -130,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)
@@ -138,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):
@@ -201,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
@@ -223,9 +356,14 @@ class ExpressionError(ValueError):
"""An NH_SEGS mask expression that cannot be read."""
class EmptyExpressionError(ExpressionError):
"""An expression with nothing in it (blank, or only commas and comments)."""
_OPERATORS = {"+": "|", "|": "|", "-": "-", "&": "&", "~": "~", "!": "~", "(": "(", ")": ")"}
_SEPARATORS = ",\n" # list items, joined like '|' but read last
_WORD_OPERATORS = {"or": "|", "and": "&", "not": "~"}
_KEYWORDS = ("all", "image", "none")
_KEYWORDS = ("all", "image", "none", "background")
_QUOTES = "\"'"
@@ -233,16 +371,23 @@ def _is_word_char(ch):
return ch.isalnum() or ch == "_"
def _tokenize(text):
"""[(kind, value, position, written)] with kinds "op", "keyword", "label"; positions count from 1.
def _word_tokens(text, word_operators):
"""[(kind, value, position, written)] with kinds "op", "sep", "word" and "quoted"; positions count from 1.
A quote opens a quoted label only where a token starts, and a '-' between two word characters is part of the
word, so ``women's`` and ``t-shirt`` are plain labels while ``person - hair`` subtracts.
word, so ``women's`` and ``t-shirt`` are plain words while ``person - hair`` subtracts. A '#' where a token would
start begins a comment that runs to the end of its line (inside a word, as in ``car#2``, it is part of the word).
"""
tokens, i, n = [], 0, len(text)
while i < n:
ch = text[i]
if ch.isspace():
if ch == "#":
end = text.find("\n", i)
i = n if end < 0 else end
elif ch in _SEPARATORS:
tokens.append(("sep", ",", i + 1, "a new line" if ch == "\n" else ch))
i += 1
elif ch.isspace():
i += 1
elif ch in _OPERATORS:
tokens.append(("op", _OPERATORS[ch], i + 1, ch))
@@ -251,34 +396,74 @@ def _tokenize(text):
end = text.find(ch, i + 1)
if end < 0:
raise ExpressionError(f"quote opened at position {i + 1} is never closed")
tokens.append(("label", text[i + 1:end], i + 1, text[i:end + 1]))
tokens.append(("quoted", text[i + 1:end], i + 1, text[i:end + 1]))
i = end + 1
else:
j = i
while j < n and not text[j].isspace():
while j < n and not text[j].isspace() and text[j] not in _SEPARATORS:
hyphen_in_word = (text[j] == "-" and _is_word_char(text[j - 1])
and j + 1 < n and _is_word_char(text[j + 1]))
if text[j] in _OPERATORS and not hyphen_in_word:
break
j += 1
word = text[i:j]
lowered = word.lower()
if lowered in _WORD_OPERATORS:
tokens.append(("op", _WORD_OPERATORS[lowered], i + 1, word))
elif lowered in _KEYWORDS:
tokens.append(("keyword", lowered, i + 1, word))
if word_operators and word.lower() in _WORD_OPERATORS:
tokens.append(("op", _WORD_OPERATORS[word.lower()], i + 1, word))
else:
tokens.append(("label", word, i + 1, word))
tokens.append(("word", word, i + 1, word))
i = j
return tokens
class _Parser:
"""expr := term (('|' | '-') term)* ; term := unary ('&' unary)* ; unary := '~' unary | atom ;
atom := label | keyword | '(' expr ')'. Builds a tuple tree evaluated by ``evaluate``."""
def _tokenize(text, word_operators=True):
"""Tokens with each run of words joined into one "label" (or "keyword") token: ``red car`` is one label.
def __init__(self, text):
self.tokens = _tokenize(text)
Labels are read through ``vi_words.translate``, so Vietnamese becomes the English the instances are named in;
the token keeps (english, status) as its value. A run that translates to all, image, none or background, unquoted,
is that keyword.
"""
raw = _word_tokens(text, word_operators)
tokens = []
for index, token in enumerate(raw):
kind, value, position, written = token
if kind == "sep" and written == "a new line":
# a formula laid out over several lines: a line break next to an operator joins, it does not list
after = next((t for t in raw[index + 1:] if not (t[0] == "sep" and t[3] == "a new line")), None)
before = next((t for t in reversed(tokens) if t[0] != "sep"), None) if tokens and tokens[-1][0] != "sep" \
else None
if (before is not None and before[0] == "op" and before[1] != ")") or (
after is not None and after[0] == "op" and after[1] in ("|", "-", "&", ")")):
continue
if kind in ("word", "quoted") and tokens and tokens[-1][0] == "run":
_, parts, start, quoted = tokens[-1]
tokens[-1] = ("run", parts + [value], start, quoted or kind == "quoted")
elif kind in ("word", "quoted"):
tokens.append(("run", [value], position, kind == "quoted"))
else:
tokens.append(token)
result = []
for token in tokens:
if token[0] != "run":
result.append(token)
continue
_, parts, position, quoted = token
written = " ".join(parts)
english, status = translate(written)
if not quoted and english.lower() in _KEYWORDS:
result.append(("keyword", english.lower(), position, written))
else:
result.append(("label", (english, status), position, written))
return result
class _Parser:
"""list := expr? (',' expr?)* ; expr := term (('|' | '-') term)* ; term := unary ('&' unary)* ;
unary := '~' unary | atom ; atom := label | keyword | '(' list ')'. Commas and new lines join the items of a list
like '|', after every other operator. Builds a tuple tree evaluated by ``evaluate``: a label node is
("label", english, written, status), a keyword node ("keyword", name, written)."""
def __init__(self, text, word_operators=True):
self.tokens = _tokenize(text, word_operators)
self.pos = 0
def _peek(self):
@@ -289,13 +474,33 @@ class _Parser:
return f"'{token[3]}' at position {token[2]}" if token else "the end"
def parse(self):
if not self.tokens:
raise ExpressionError("the expression is empty; use 'all' for every instance")
tree = self._expr()
if self._peek() is not None:
raise ExpressionError(f"unexpected {self._where()}")
items = self._list(nested=False)
if not items:
raise EmptyExpressionError("the expression is empty; use 'all' for every instance")
return self._union(items)
@staticmethod
def _union(items):
tree = items[0]
for item in items[1:]:
tree = ("|", tree, item)
return tree
def _list(self, nested):
"""The items of a list up to the end (or the closing ')' when ``nested``); empty items are skipped."""
items = []
while True:
token = self._peek()
if token is not None and token[0] == "sep":
self.pos += 1
continue
if token is None or (nested and token[:2] == ("op", ")")):
return items
items.append(self._expr())
token = self._peek()
if token is not None and token[0] != "sep" and not (nested and token[:2] == ("op", ")")):
raise ExpressionError(f"unexpected {self._where()}")
def _expr(self):
tree = self._term()
while (token := self._peek()) is not None and token[0] == "op" and token[1] in ("|", "-"):
@@ -324,27 +529,37 @@ class _Parser:
kind, value, position, written = token
self.pos += 1
if kind == "label":
return ("label", value)
return ("label", value[0], written, value[1])
if kind == "keyword":
return ("keyword", value)
return ("keyword", value, written)
if value == "(":
tree = self._expr()
items = self._list(nested=True)
closing = self._peek()
if closing is None or closing[:2] != ("op", ")"):
raise ExpressionError(f"'(' at position {position} is not closed (found {self._where()})")
if not items:
raise ExpressionError(f"'(' at position {position} holds nothing")
self.pos += 1
return tree
return self._union(items)
raise ExpressionError(f"unexpected '{written}' at position {position}")
@functools.lru_cache(maxsize=64)
def parse_expression(text):
"""Parse a mask expression (see ``evaluate``) into a tree; raises ExpressionError with the position on mistakes."""
return _Parser(str(text)).parse()
def parse_expression(text, word_operators=True):
"""Parse a mask expression (see ``evaluate``) into a tree; raises ExpressionError with the position on mistakes.
``word_operators=False`` reads or/and/not as words, for the natural phrases SAM 3 takes ("black and white dog").
"""
return _Parser(str(text), word_operators).parse()
def parse_concepts(text):
"""``parse_expression`` for SAM3 Segment's concepts: or/and/not are plain words there."""
return parse_expression(text, word_operators=False)
def expression_labels(tree):
"""Label patterns used in a parsed expression, in order."""
"""Label patterns (in English) used in a parsed expression, in order."""
if tree[0] == "label":
return [tree[1]]
if tree[0] == "keyword":
@@ -352,20 +567,36 @@ def expression_labels(tree):
return [label for child in tree[1:] for label in expression_labels(child)]
def expression_terms(tree):
"""(written, english, status) of each label and keyword of a parsed expression, in order; status is "english",
"translated" or "unknown" (Vietnamese not in vi_words) for labels and "keyword" for keywords."""
if tree[0] == "label":
return [(tree[2], tree[1], tree[3])]
if tree[0] == "keyword":
return [(tree[2], tree[1], "keyword")]
return [term for child in tree[1:] for term in expression_terms(child)]
def uses_keyword(tree, name):
return any(status == "keyword" and english == name for _, english, status in expression_terms(tree))
def evaluate(tree, frame):
"""Bool [H, W] mask of a parsed expression for one frame.
A label (wildcards allowed; quotes or '_' for spaces; a '-' inside a word is part of it) is the union of the
matching instances,
``all`` every instance, ``image`` the whole frame, ``none`` nothing. ``a | b`` (also ``+``, ``or``) is the union,
A label (wildcards allowed; quotes or '_' for spaces; a '-' inside a word is part of it; words in a row make one
label) is the union of the matching instances, ``all`` every instance, ``background`` everything outside them,
``image`` the whole frame, ``none`` nothing. ``a | b`` (also ``+``, ``or``, a comma or a new line) is the union,
``a - b`` the difference, ``a & b`` (``and``) the intersection, ``~a`` (``!``, ``not``) everything outside ``a``.
"""
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)
if tree[1] == "background":
return ~frame_mask(frame)
if tree[1] == "image":
return torch.ones((frame.height, frame.width), dtype=torch.bool)
return torch.zeros((frame.height, frame.width), dtype=torch.bool)
@@ -495,6 +726,116 @@ def render_preview(image, frame, fill=0.45):
return np.asarray(picture)
def scale_frame(frame, scale):
"""The frame of the picture made ``scale`` times smaller (for previews): boxes scaled, masks area-resampled.
An instance too small to keep a pixel keeps its centre pixel, so it still shows up with its label.
"""
height, width = max(1, round(frame.height * scale)), max(1, round(frame.width * scale))
segments = []
for seg in frame.segments:
x1, y1, x2, y2 = seg.box
nx1, ny1 = min(width - 1, int(x1 * scale)), min(height - 1, int(y1 * scale))
nx2 = max(nx1 + 1, min(width, int(np.ceil(x2 * scale))))
ny2 = max(ny1 + 1, min(height, int(np.ceil(y2 * scale))))
small = cv2.resize(seg.mask.numpy().astype(np.uint8) * 255, (nx2 - nx1, ny2 - ny1),
interpolation=cv2.INTER_AREA) >= 128
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), frame.picture)
def _caption(picture, notes):
"""``picture`` (uint8 RGB) with ``notes`` written on a white strip below it, one line each, long lines wrapped."""
width = picture.shape[1]
font = _label_font(max(13, round(width / 70)))
measure = ImageDraw.Draw(Image.new("RGB", (1, 1)))
lines = []
for note in notes:
words, line = str(note).split(), ""
for word in words:
candidate = f"{line} {word}".strip()
if line and measure.textlength(candidate, font=font) > width - 16:
lines.append(line)
line = word
else:
line = candidate
lines.append(line)
left, top, right, bottom = measure.textbbox((0, 0), "Ag", font=font)
step = bottom - top + 6
strip = Image.new("RGB", (width, step * len(lines) + 10), (255, 255, 255))
draw = ImageDraw.Draw(strip)
for index, line in enumerate(lines):
draw.text((8, 5 + index * step - top), line, font=font, fill=(20, 20, 20))
return np.concatenate([picture, np.asarray(strip)], axis=0)
def preview_picture(image, frame, notes=(), max_side=1536, mask=None):
"""uint8 RGB preview of one picture, at most ``max_side`` px, its instances drawn and ``notes`` written below.
``image`` is one ComfyUI image [H, W, C] (float, 0..1). With ``mask`` (the node's [H, W] output) everything
outside it is darkened, so the preview shows what the mask holds ("background" lights up the background) while the
instances keep their outline and label. The picture is shrunk first (area averaging), so an 8K image with a
hundred instances needs a few MB more, not the full-size float preview Segs Preview makes.
"""
rgb = image[..., :3] if image.shape[-1] >= 3 else image[..., :1].expand(-1, -1, 3)
scale = min(1.0, max_side / max(frame.height, frame.width))
size = (max(1, round(frame.height * scale)), max(1, round(frame.width * scale)))
if scale < 1.0: # shrink before anything else, so no full-size copy of an 8K picture is ever made
rgb = F.interpolate(rgb.permute(2, 0, 1)[None].float(), size=size, mode="area")[0].permute(1, 2, 0)
frame = scale_frame(frame, scale)
pixels = (rgb.float().clamp(0.0, 1.0) * 255.0).round().to(torch.uint8).cpu().numpy()
if mask is not None:
held = mask.float()
if scale < 1.0:
held = F.interpolate(held[None, None], size=size, mode="area")[0, 0]
outside = held.cpu().numpy() < 0.5
pixels = pixels.copy()
pixels[outside] = (pixels[outside] * _DIM_OUTSIDE).astype(np.uint8)
picture = render_preview(pixels, frame, fill=0.2 if mask is not None else 0.45)
return _caption(picture, notes) if notes else picture
_DIM_OUTSIDE = 0.35 # brightness kept outside the mask in previews
def describe_request(tree, segs, threshold=None, misses=None, added=()):
"""Lines telling what each label and keyword of a parsed request found in ``segs``, for reports and previews.
``misses`` maps a concept to the best score its candidates reached under ``threshold``; ``added`` names concepts
the node searched for on its own (person, for background).
"""
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", fold_label(english))
if key in seen:
continue
seen.add(key)
if status == "keyword":
if english == "background":
names = list(dict.fromkeys(seg.label for seg in instances))
outside = ", ".join(names) if names else "nothing (no instance was found)"
lines.append(f"background = everything outside {outside}")
continue
name = f"{written} -> {english}" if status == "translated" else written
found = [seg for seg in instances if label_matches(seg.label, english)]
if found:
lines.append(f"{name}: {len(found)} found (best {max(seg.score for seg in found):.2f})")
elif status == "unknown":
lines.append(f"{written}: not understood; write it in English (SAM 3 reads English only)")
elif misses and english in misses and threshold is not None:
lines.append(f"{name}: none at threshold {threshold:.2f}; the best candidate scored "
f"{misses[english]:.2f}, lower the threshold to keep it")
else:
lines.append(f"{name}: none found; try another English word")
for name in added:
found = [seg for seg in instances if label_matches(seg.label, name)]
lines.append(f"{name} (searched for background): {len(found)} found")
return lines
def preview_batch(image, segs):
"""IMAGE batch with every instance of ``segs`` drawn on its image (tinted, outlined, labelled)."""
if len(segs) != image.shape[0]:
+33
View File
@@ -0,0 +1,33 @@
"""Pictures a node shows on itself in the ComfyUI editor (its "ui" images), saved to ComfyUI's temp folder.
A node returns ``{"ui": {"images": save_temp_previews(...)}, "result": (...)}`` and the editor draws the pictures
on the node, so nobody has to wire a Preview Image node to see what it did.
"""
import os
import uuid
from PIL import Image
MAX_PREVIEWS = 16 # pictures per run; a long batch shows its first ones
def save_temp_previews(pictures, prefix):
"""Save uint8 RGB arrays as JPEG files in ComfyUI's temp folder; the "ui" image entries for them.
Outside ComfyUI (tests, scripts) there is no temp folder to serve them from, and nothing is saved.
"""
try:
import folder_paths
folder = folder_paths.get_temp_directory()
except Exception:
return []
os.makedirs(folder, exist_ok=True)
entries = []
run = uuid.uuid4().hex[:12]
for index, picture in enumerate(pictures[:MAX_PREVIEWS]):
name = f"{prefix}_{run}_{index}.jpg"
Image.fromarray(picture).save(os.path.join(folder, name), quality=90)
entries.append({"filename": name, "subfolder": "", "type": "temp"})
return entries
+150
View File
@@ -0,0 +1,150 @@
"""Vietnamese words for the things NH vision nodes look for, in the English SAM 3 and the parsers understand.
SAM 3 reads English only: "tóc" finds nothing where "hair" finds the hair. ``translate`` turns the Vietnamese words
people type most for photos (people, body, clothes, accessories, rooms, buildings, nature) into English, including a
thing followed by colours or sizes ("áo sơ mi trắng" -> "white shirt"). English passes through unchanged; Vietnamese
it does not know is reported, so the node can say what to write instead.
"""
import re
import unicodedata
# Things. Longer phrases win over their first word ("cửa sổ" over "cửa"), so both may be listed.
NOUNS = {
# people and body
"người": "person", "con người": "person", "người mẫu": "person", "mọi người": "person",
"phụ nữ": "woman", "người phụ nữ": "woman", "đàn bà": "woman", "người đàn bà": "woman", "cô gái": "girl",
"con gái": "girl", "bé gái": "girl", "đàn ông": "man", "người đàn ông": "man", "chàng trai": "man",
"con trai": "boy", "bé trai": "boy",
"trẻ em": "child", "em bé": "baby", "cô dâu": "bride", "chú rể": "groom",
"cơ thể": "body", "thân người": "body", "đầu": "head",
"mặt": "face", "khuôn mặt": "face", "gương mặt": "face",
"tóc": "hair", "mái tóc": "hair", "tóc vàng": "blonde hair",
"da": "skin", "làn da": "skin", "da mặt": "face skin", "da người": "skin",
"mắt": "eye", "đôi mắt": "eye", "lông mày": "eyebrow", "chân mày": "eyebrow", "lông mi": "eyelash",
"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",
"áo phông": "t-shirt", "áo khoác": "jacket", "áo vest": "suit jacket", "áo len": "sweater",
"áo dài": "ao dai", "áo choàng": "coat", "áo măng tô": "coat", "áo ngực": "bra",
"váy": "dress", "đầm": "dress", "váy cưới": "wedding dress", "chân váy": "skirt",
"quần": "pants", "quần dài": "pants", "quần âu": "trousers", "quần jean": "jeans", "quần bò": "jeans",
"quần short": "shorts", "quần đùi": "shorts",
"giày": "shoe", "giày cao gót": "high heels", "dép": "sandal", "tất": "sock", "vớ": "sock",
"mũ": "hat", "nón": "hat", "khăn": "scarf", "khăn quàng": "scarf", "cà vạt": "tie",
"thắt lưng": "belt", "dây lưng": "belt", "túi": "bag", "túi xách": "handbag", "ví": "wallet",
"ba lô": "backpack", "balo": "backpack", "kính": "glasses", "mắt kính": "glasses",
"kính mát": "sunglasses", "kính râm": "sunglasses", "đồng hồ": "watch", "nhẫn": "ring",
"vòng cổ": "necklace", "dây chuyền": "necklace", "vòng tay": "bracelet", "lắc tay": "bracelet",
"bông tai": "earring", "khuyên tai": "earring", "hoa tai": "earring", "trang sức": "jewelry",
"găng tay": "glove", "ô": "umbrella",
# nature and outdoors
"trời": "sky", "bầu trời": "sky", "mây": "cloud", "đám mây": "cloud", "mặt trời": "sun",
"cây": "tree", "cây cối": "tree", "lá": "leaf", "lá cây": "leaf", "cỏ": "grass", "bãi cỏ": "lawn",
"hoa": "flower", "bông hoa": "flower", "bụi cây": "bush", "núi": "mountain", "đồi": "hill",
"biển": "sea", "bãi biển": "beach", "cát": "sand", "nước": "water", "sông": "river", "hồ": "lake",
"đá": "rock", "đường": "road", "con đường": "road", "vỉa hè": "sidewalk", "cầu": "bridge",
# buildings and rooms
"nhà": "house", "ngôi nhà": "house", "tòa nhà": "building", "toà nhà": "building",
"mái": "roof", "mái nhà": "roof", "tường": "wall", "bức tường": "wall", "cửa": "door",
"cửa ra vào": "door", "cửa sổ": "window", "ban công": "balcony", "cầu thang": "stairs",
"cột": "pillar", "hàng rào": "fence", "sàn": "floor", "sàn nhà": "floor", "nền nhà": "floor",
"trần": "ceiling", "trần nhà": "ceiling", "đèn": "lamp", "đèn trần": "ceiling light",
"rèm": "curtain", "rèm cửa": "curtain", "thảm": "rug", "tranh": "painting", "bức tranh": "painting",
"gương": "mirror", "kệ": "shelf", "tủ": "cabinet", "bàn": "table", "ghế": "chair",
"ghế sofa": "sofa", "giường": "bed", "gối": "pillow", "chăn": "blanket", "cây cảnh": "potted plant",
"chậu cây": "potted plant", "lọ hoa": "vase", "bình hoa": "vase", "ti vi": "television",
"tivi": "television", "máy tính": "computer", "điện thoại": "phone", "sách": "book",
# vehicles, food, objects, animals
"xe": "vehicle", "ô tô": "car", "xe hơi": "car", "xe máy": "motorcycle", "xe đạp": "bicycle",
"xe buýt": "bus", "thuyền": "boat", "máy bay": "airplane",
"cốc": "cup", "tách": "cup", "ly": "glass", "chai": "bottle", "đĩa": "plate", "bát": "bowl",
"chén": "bowl", "bánh": "cake", "đồ ăn": "food", "thức ăn": "food", "hộp": "box",
"sản phẩm": "product", "chữ": "text", "biển hiệu": "sign", "bảng hiệu": "sign",
"chó": "dog", "mèo": "cat", "chim": "bird", "ngựa": "horse", "cá": "fish",
# mask formula keywords
"nền": "background", "phông": "background", "phông nền": "background", "hậu cảnh": "background",
"tất cả": "all", "toàn ảnh": "image", "cả ảnh": "image",
}
# Words that describe a thing and come after it in Vietnamese ("áo đỏ" = "red top"). "xanh" alone is left out on
# purpose: it means blue or green, so the node asks for "xanh dương" / "xanh lá" (or English) instead.
ADJECTIVES = {
"đỏ": "red", "xanh dương": "blue", "xanh da trời": "blue", "xanh lam": "blue", "xanh lá": "green",
"xanh lá cây": "green", "xanh lục": "green", "vàng": "yellow", "trắng": "white", "đen": "black",
"hồng": "pink", "tím": "purple", "cam": "orange", "nâu": "brown", "xám": "gray", "ghi": "gray",
"bạc": "silver", "be": "beige", "kem": "cream",
"lớn": "large", "to": "large", "nhỏ": "small", "dài": "long", "ngắn": "short",
}
# Words that only count or point ("các áo" = the tops) and the word for colour ("áo màu đỏ" = a red top).
_LEADING = ("các", "những", "mấy", "cái", "chiếc", "con")
_COLOUR_WORD = "màu"
_VIETNAMESE = re.compile("[àáạảãâầấậẩẫăằắặẳẵèéẹẻẽêềếệểễìíịỉĩòóọỏõôồốộổỗơờớợởỡùúụủũưừứựửữỳýỵỷỹđ]")
def normalize(text):
"""Lower case, composed accents (what IMEs and pasted text differ in), single spaces."""
return " ".join(unicodedata.normalize("NFC", str(text)).lower().split())
def is_vietnamese(text):
"""True when ``text`` has a letter only Vietnamese uses (an accent, đ)."""
return bool(_VIETNAMESE.search(normalize(text)))
def _longest_prefix(words, table):
"""(English, number of words) for the longest leading phrase of ``words`` found in ``table``, or (None, 0)."""
for size in range(len(words), 0, -1):
english = table.get(" ".join(words[:size]))
if english is not None:
return english, size
return None, 0
def translate(text):
"""(English words, status) for one thing someone typed; status is "english", "translated" or "unknown".
A whole phrase is looked up first, then a thing followed by colours or sizes. Text without Vietnamese letters is
taken as English and returned unchanged, as is Vietnamese that is not in the lists ("unknown").
"""
phrase = normalize(text)
if not is_vietnamese(phrase) and phrase not in NOUNS:
return str(text).strip(), "english"
words = phrase.split()
english = _phrase(words)
while english is None and len(words) > 1 and words[0] in _LEADING: # "những chiếc xe": drop the counting words
words = words[1:]
english = _phrase(words)
return (english, "translated") if english is not None else (str(text).strip(), "unknown")
def _phrase(words):
"""English for a thing followed by colours or sizes, or None."""
if " ".join(words) in NOUNS:
return NOUNS[" ".join(words)]
noun, used = _longest_prefix(words, NOUNS)
if noun is None:
return None
adjectives, rest = [], words[used:]
while rest:
if rest[0] == _COLOUR_WORD:
rest = rest[1:]
continue
english, size = _longest_prefix(rest, ADJECTIVES)
if english is None:
return None
adjectives.append(english)
rest = rest[size:]
if noun == "background":
return "background" # "nền trắng": the keyword, which needs no colour
return " ".join([*adjectives, noun])
+95
View File
@@ -3,6 +3,101 @@
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.
### Added
- `SAM3 Segment (NH)` returns the mask of a formula, so one node gives "the person without the bag" or "the background". In `concepts`, commas or new lines list several things (their union, applied after everything else); `+` and `|` join, `-` removes (put spaces around it), `&` keeps the overlap, `~` is everything except, parentheses group, and `background` is everything outside all the instances that were found: `person - bag`, `hair, face`, `~person`, `background`. Several words in a row are one label (`red car`, no quotes), and `#` starts a comment where a word could start. Here `or`, `and` and `not` are plain words, so a phrase such as `black and white dog` reaches SAM 3 as written, and wildcards are refused (they work in Segs Select and Segs Compose). When a request uses `background` and does not name `person`, SAM 3 also looks for `person` on its own; those instances are part of `segs` and `count`.
- `SAM3 Segment (NH)` has a new output, `report` (STRING, last): for each concept the number found and the best score, the English a Vietnamese word became, a concept that was not found with the best score it reached under `threshold`, words that were not understood, and what `background` meant.
- `SAM3 Segment (NH)` shows its result on the node (new optional input `show_preview`, on by default): the mask bright and the rest darkened, the instances outlined and labelled, and the report lines below. The preview is at most 1536 px and shows up to 16 pictures of a batch.
- Common Vietnamese words work in `SAM3 Segment (NH)`, `Segs Compose (NH)` and `Segs Select (NH)`: people, body, clothes, accessories, nature, rooms, vehicles, food and animals. A thing followed by colours or sizes is reordered (`áo đỏ` is `red top`, `tóc dài` is `long hair`, `tóc vàng` is `blonde hair`). `nền`, `phông`, `phông nền` and `hậu cảnh` (also with a colour, as in `nền trắng`) mean `background`. `xanh` after a thing is ambiguous (blue or green) and is reported: write `xanh dương` or `xanh lá`. The labels of the instances are the English words. The word list is `core/vi_words.py`.
- `Segs Compose (NH)` reads the same formula language: commas and line breaks list several items, `background` (the same as `~all`) is new, several words in a row are one label (`red car`; quotes are still allowed and keep `all`, `image`, `none` and `background` as ordinary labels), `#` starts a comment, and `or`, `and`, `not` and the wildcards work as before. When `person hair` finds no label but `person` and `hair` both exist, the node stops and suggests `person + hair`.
- `Portrait Segment (NH)` and `Garment Segment (NH)` find people with YuNet, a face detector (about 230 KB, MIT licence, from OpenCV Zoo). It is downloaded once into `models/NH-Nodes/yunet` and runs on the CPU with the OpenCV that NH-Nodes already needs. If it cannot be loaded (offline on the first run, or an OpenCV that cannot run it), the console prints one warning and the nodes parse whole pictures.
- `Portrait Segment (NH)` parses each face on close-ups. The whole picture is parsed first (as a padded square, no longer squeezed). Faces are looked for on two sizes of the picture (at most 32; when none is found, the picture gets a grey margin and is searched once more, which finds a face cut by the frame), and each one is confirmed by the parser on a tight crop. A wide crop (hair, neck) and the tight crop (eyes, eyebrows, nose, lips, only inside an ellipse around the face) then refine the whole-picture parse. A face that already fills most of the frame keeps the whole-picture parse. Half-body and group photos now get eyes, eyebrows, nose and lips, and group photos get every face.
- `Garment Segment (NH)` parses groups of people on crops. The whole picture is parsed first and kept. Groups of people (found from that parse and from faces) get crops of the model's shape (a row of crops for a wide group) that are parsed at the model's full resolution, and a crop grows when people run past its edge. A crop's labels replace the first look only where they are surer, away from the crop's edges, and only for parts on people already found, so nothing is invented on an empty backdrop.
### Changed
- (behaviour change) `SAM3 Segment (NH)`: a concept list that contains a keyword (`all`, `image`, `none`, `background`) or an operator character (`+ - & ~ ! | ( )`) now acts as a formula. Lists of plain words give the same result as before. `person, background` is the whole picture (a list is a union); `salt & pepper shaker` is the overlap of `salt` and `pepper shaker` (put it in quotes to keep one label); `person (adult)` is an error; in `car #2` the `#2` is a comment, so it finds `car`.
- (behaviour change) `Segs Compose (NH)`: a line break between two labels now lists them (it used to count as a space, which made it an error). Next to an operator a line break still joins, so formulas laid out over several lines keep working. Words in a row are one label: `person hair` used to be an error and is now a label (with the hint above when both parts exist), and `red car` needs no quotes.
- (behaviour change) `Portrait Segment (NH)` and `Garment Segment (NH)` masks change on photos where the subject does not fill the frame; they are better (see Added). The whole-picture parse of Portrait Segment, which is also the whole result when no face is found, is now a padded square instead of the picture squeezed into the model's square. A picture with a side longer than twice the model's 512 px is shrunk with area averaging before the model sees it, which is faster and keeps fine detail clean, so masks of big photos can differ slightly. On a photo longer than 4096 px the label map is worked out on a 4096 px copy and enlarged at the end (the models see 512 px at a time, so nothing is lost). Once faces are found, face parts that the whole-picture parse saw far from all of them (a reflection, a pattern) are dropped.
- `SAM3 Segment (NH)` is an output node: ComfyUI runs it even when nothing is connected to it. A copy you no longer need keeps running SAM 3 whenever its inputs change, until you mute or delete it.
- The `mask` of `SAM3 Segment (NH)` is the result of the formula. A plain list of words still gives the union of every instance. When nothing is found, the mask is empty for a plain list and the whole picture for `background` or `~person`.
- `SAM3 Segment (NH)` asks SAM 3 for candidates down to a score of 0.1 and applies `threshold` itself, so the report can say how close a missed concept came. The instances kept are the same as before: a score exactly at `threshold` is dropped, as SAM 3 does. Instances with exactly equal scores keep the order SAM 3 gave them (the sort used to be unspecified).
### Fixed
- (behaviour change) SAM 3 masks of things that run off the picture now reach the image edge. SAM 3 fades its masks along the border of the square view it works in (the last 3 of 1008 rows and columns), which left a ragged gap along the edge of a person cut by the frame. That band is now lifted where the object reaches it from inside, also in the close-ups of `balanced`. A thing that ends within the band (about 0.3% of the picture's side) above the edge is drawn down to the edge.
- (behaviour change) `Portrait Segment (NH)` found few or no eyes, eyebrows, nose and lips on half-body shots, and missed faces in group photos, because the model saw a small face squeezed into 512 px. `Garment Segment (NH)` had the same problem with small people. See Added.
### Notes
- ComfyUI's Unload Models button does not release SAM 3's VRAM while `keep_model_loaded` is on. Turn it off and run the node once, or restart ComfyUI.
- Known limit: the Fashionpedia model behind `Garment Segment (NH)` misses some tops, for example a blouse under a big hat.
### Planned
Not part of this release, and the names may change: a better garment model.
## 1.7.1 — SAM3 Segment with other SAM 3 packs installed (2026-10-18)
### Fixed
+121 -18
View File
@@ -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
@@ -1062,8 +1065,8 @@ def ex_vton_masks():
SAM3_CONCEPTS = "person, helmet, face"
@example("sam3-segment.jpg")
def ex_sam3_segment():
def require_sam3():
"""Skip the SAM 3 examples unless they can run here: --gpu, the sam3 package and models/sam3/sam3.pt."""
if not GPU or not torch.cuda.is_available():
raise Skip("SAM 3 needs a CUDA GPU: run with --gpu")
if importlib.util.find_spec("sam3") is None:
@@ -1072,10 +1075,15 @@ def ex_sam3_segment():
importlib.import_module("NH-Nodes.core.sam3_backend").resolve_checkpoint("sam3.pt")
except RuntimeError:
raise Skip("models/sam3/sam3.pt is missing") from None
@example("sam3-segment.jpg")
def ex_sam3_segment():
require_sam3()
image = photo("astronaut")
segs, _mask, count = run("NH_SAM3Segment", image=to_tensor(image), concepts=SAM3_CONCEPTS, threshold=0.5,
detail_level="balanced", max_per_concept=0, checkpoint="sam3.pt",
keep_model_loaded=False)
segs, _mask, count, _report = run("NH_SAM3Segment", image=to_tensor(image), concepts=SAM3_CONCEPTS,
threshold=0.5, detail_level="balanced", max_per_concept=0,
checkpoint="sam3.pt", keep_model_loaded=False, show_preview=False)
(preview,) = run("NH_SegsPreview", image=to_tensor(image), segs=segs)
def compose(expression):
@@ -1104,6 +1112,101 @@ def ex_sam3_segment():
return render_panel([top, bottom])
@example("sam3-formula.jpg")
def ex_sam3_formula():
"""What SAM3 Segment shows on the node itself: the picture its ui images hold, for three formulas."""
require_sam3()
import tempfile
import folder_paths
image = photo("astronaut")
formulas = ("background", "person - face", "hair, face", "~(person + helmet)")
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 index, concepts in enumerate(formulas):
result = run("NH_SAM3Segment", image=to_tensor(image), concepts=concepts, threshold=0.5,
detail_level="balanced", max_per_concept=0, checkpoint="sam3.pt",
keep_model_loaded=index < len(formulas) - 1, show_preview=True)
with Image.open(Path(folder) / result["ui"]["images"][0]["filename"]) as picture:
shown[concepts] = picture.convert("RGB")
finally:
folder_paths.set_temp_directory(previous)
def picture_only(concepts): # the report is written on a strip under the picture: keep the picture
return shown[concepts].crop((0, 0, image.width, image.height))
top = Row(
[Tile(image, "image", "input photo"), ARROW,
Tile(shown["background"], "concepts: background", "everything outside the person; the report is under the picture")],
"SAM3 Segment (NH)", "what the node shows on itself: the mask is bright, the rest darkened", max_h=470,
)
bottom = Row(
[Tile(picture_only("person - face"), "person - face", "the person without the face", aspect=1.0),
Tile(picture_only("hair, face"), "hair, face", "a list: the hair and the face together", aspect=1.0),
Tile(picture_only("~(person + helmet)"), "~(person + helmet)", "everything except the person and the helmets",
aspect=1.0)],
"More formulas", "one SAM3 Segment (NH) each; the bright part is its mask output", max_h=330,
)
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
# ---------------------------------------------------------------------------------------------------------------
Binary file not shown.

Before

Width:  |  Height:  |  Size: 147 KiB

After

Width:  |  Height:  |  Size: 147 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 159 KiB

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 198 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 114 KiB

After

Width:  |  Height:  |  Size: 115 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 123 KiB

After

Width:  |  Height:  |  Size: 125 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 136 KiB

After

Width:  |  Height:  |  Size: 137 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 81 KiB

After

Width:  |  Height:  |  Size: 81 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 170 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 193 KiB

After

Width:  |  Height:  |  Size: 193 KiB

+50
View File
@@ -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

+37 -30
View File
@@ -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

+59 -57
View File
@@ -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

+36 -30
View File
@@ -1,7 +1,7 @@
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="338" viewBox="0 0 360 338" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="SAM3 Segment (NH) node">
<rect x="0.5" y="0.5" width="359" height="337" rx="10" fill="#26282c" stroke="#45484f"/>
<svg xmlns="http://www.w3.org/2000/svg" width="360" height="389" viewBox="0 0 360 389" font-family="Inter, 'Segoe UI', Roboto, Helvetica, Arial, sans-serif" role="img" aria-label="SAM3 Segment (NH) node">
<rect x="0.5" y="0.5" width="359" height="388" 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"/>
<circle cx="17" cy="17" r="4.5" fill="#F6AD55"/>
<text x="28" y="22" font-size="14" font-weight="600" fill="#EEF0F3">SAM3 Segment (NH)</text>
<text x="28" y="38" font-size="10.5" fill="#9AA0A8" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">NH_SAM3Segment · Vision</text>
<circle cx="12" cy="67.0" r="5" fill="#64B5F6" stroke="#1b1c1f" stroke-width="1.5"/>
@@ -12,31 +12,37 @@
<circle cx="348" cy="89.0" r="5" fill="#81C784" stroke="#1b1c1f" stroke-width="1.5"/>
<text x="336" y="115.0" font-size="12" fill="#D7DADF" text-anchor="end">count</text>
<circle cx="348" cy="111.0" r="5" fill="#9CC2E5" stroke="#1b1c1f" stroke-width="1.5"/>
<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">concepts</text>
<text x="22" y="166" font-size="11.5" fill="#E6E8EB" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">person</text>
<rect x="12" y="181" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="20" y="197" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="197" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="197" font-size="11.5" fill="#9AA0A8">threshold</text>
<text x="326" y="197" font-size="11.5" fill="#E6E8EB" text-anchor="end">0.50</text>
<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">detail_level</text>
<text x="326" y="226" font-size="11.5" fill="#E6E8EB" text-anchor="end">balanced</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">max_per_concept</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="20" y="284" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="284" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="284" font-size="11.5" fill="#9AA0A8">checkpoint</text>
<text x="326" y="284" font-size="11.5" fill="#E6E8EB" text-anchor="end">(choose a file)</text>
<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">keep_model_loaded</text>
<rect x="308" y="303" width="30" height="13" rx="6.5" fill="#4a4d53"/>
<circle cx="315" cy="309.5" r="5" fill="#F5F6F7"/>
<text x="336" y="137.0" font-size="12" fill="#D7DADF" text-anchor="end">report</text>
<circle cx="348" cy="133.0" r="5" fill="#C5E1A5" stroke="#1b1c1f" stroke-width="1.5"/>
<rect x="12" y="152" width="336" height="46" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="22" y="168" font-size="11.5" fill="#9AA0A8">concepts</text>
<text x="22" y="188" font-size="11.5" fill="#E6E8EB" font-family="'JetBrains Mono', Consolas, 'Courier New', monospace">person</text>
<rect x="12" y="203" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="20" y="219" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="219" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="219" font-size="11.5" fill="#9AA0A8">threshold</text>
<text x="326" y="219" font-size="11.5" fill="#E6E8EB" text-anchor="end">0.50</text>
<rect x="12" y="232" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="20" y="248" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="248" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="248" font-size="11.5" fill="#9AA0A8">detail_level</text>
<text x="326" y="248" font-size="11.5" fill="#E6E8EB" text-anchor="end">balanced</text>
<rect x="12" y="261" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="20" y="277" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="277" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="277" font-size="11.5" fill="#9AA0A8">max_per_concept</text>
<text x="326" y="277" font-size="11.5" fill="#E6E8EB" text-anchor="end">0</text>
<rect x="12" y="290" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="20" y="306" font-size="10" fill="#6f747c">◀</text>
<text x="332" y="306" font-size="10" fill="#6f747c">▶</text>
<text x="34" y="306" font-size="11.5" fill="#9AA0A8">checkpoint</text>
<text x="326" y="306" font-size="11.5" fill="#E6E8EB" text-anchor="end">(choose a file)</text>
<rect x="12" y="319" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="22" y="335" font-size="11.5" fill="#9AA0A8">keep_model_loaded</text>
<rect x="308" y="325" width="30" height="13" rx="6.5" fill="#4a4d53"/>
<circle cx="315" cy="331.5" r="5" fill="#F5F6F7"/>
<rect x="12" y="348" width="336" height="24" rx="7" fill="#1d1f22" stroke="#3a3d43"/>
<text x="22" y="364" font-size="11.5" fill="#9AA0A8">show_preview <tspan fill="#6f747c" font-style="italic">opt</tspan></text>
<rect x="308" y="354" width="30" height="13" rx="6.5" fill="#4C8BF5"/>
<circle cx="331" cy="360.5" r="5" fill="#F5F6F7"/>
</svg>

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

+13 -5
View File
@@ -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

+6
View File
@@ -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)",
+13 -9
View File
@@ -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 instance masks for
anything you can name, 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.
+34 -9
View File
@@ -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**
+2 -1
View File
@@ -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, refined with [Segs Compose (NH)](./vision.md#segs-compose-nh)), [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.
---
@@ -172,6 +172,7 @@ Set `feathering` to `20`. Because a single source is reused, every image of the
- Using masks whose boxes have very different proportions. The face looks squashed or stretched.
- Giving a mask that has another size than its image.
- Using a [Portrait Segment (NH)](./vision.md#portrait-segment-nh) mask of a photo with several faces. The mask holds every face, so the box around it covers them all.
- Setting `feathering` very high for a small face. The blur can eat the whole shape.
> **Tip:** Make `dest_mask` slightly larger than the face you replace, and keep `source_mask` tight. The soft edge of the source mask then blends into the destination.
+421 -113
View File
@@ -2,23 +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: SAM3 Segment finds every instance of anything you can name in plain English (people, a wine glass, windows, the sky), and five Segs nodes pick, combine, preview and convert what it found. 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**
- [Before you start](#before-you-start) — models, first run, licences, memory
- [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
- [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, 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
- [SAM3 Segment (NH)](#sam3-segment-nh) — find every instance of the concepts you name
- [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
@@ -29,43 +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: shirt, trousers, shoes, hat | [Garment Segment](#garment-segment-nh) | A SegFormer model that downloads itself on the first run. Runs on the CPU or the GPU. |
| The face, hair, neck and facial features of a portrait | [Portrait Segment](#portrait-segment-nh) | The same. |
| Anything you can name (people, objects, sky, walls, windows), 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). |
| 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. 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.
**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.
**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.
> **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.
**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.
> **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. Read [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) before you use either node commercially.
> **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:** 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
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 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 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.
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 |
|---|---|---|
@@ -81,43 +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 a person 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. |
| `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. |
| `image` | IMAGE | — | A photo of a person or of a group of people. |
| `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`.
- **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**
@@ -127,7 +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.
> **Licence:** the garment model declares no licence. See [Before you start](#before-you-start).
> **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:** 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).
---
@@ -135,13 +177,13 @@ Grow the mask a few pixels with [Mask Morphology (NH)](./mask.md#mask-morphology
<p align="center"><img src="../assets/nodes/NH_PortraitSegment.svg" alt="Portrait Segment (NH) node" width="360"></p>
Masks the face, hair, neck and facial features of a portrait. You tick the regions you want.
Masks the face, hair, neck and facial features of a portrait, or of every face in a group photo. You tick the regions you want.
**Use it when** you want to retouch or recolour hair, work on skin and facial features, or protect the face while you edit the rest of the picture.
| Input | Type | Default | Description |
|---|---|---|---|
| `image` | IMAGE | — | A portrait photo. |
| `image` | IMAGE | — | A portrait, a half-body shot or a group photo. |
| `face` | BOOLEAN | `true` | The solid face region: skin, nose, eyes, eyebrows, eyeglasses, lips and the inside of the mouth. |
| `neck` | BOOLEAN | `true` | The neck. |
| `hair` | BOOLEAN | `true` | The hair. |
@@ -158,15 +200,20 @@ Masks the face, hair, neck and facial features of a portrait. You tick the regio
| Output | Type | Description |
|---|---|---|
| `mask` | MASK | The combined mask of every ticked region. |
| `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.
- **Large faces.** A face that already fills a large part of the frame is left to the whole-picture parse, which shows it large enough. See [How the SegFormer nodes look at a photo](#how-the-segformer-nodes-look-at-a-photo).
<p align="center"><img src="../assets/examples/portrait-segment.jpg" alt="Portrait Segment masks overlaid on a sample photo" width="720"><br><em>Portrait Segment on a sample photo: the coloured areas are the masks of the ticked regions.</em></p>
@@ -178,12 +225,177 @@ Load Image → Portrait Segment (NH) → mask → recolour or inpaint
Switch off every region except `hair`. With the defaults you get face, ears, neck and hair together in one mask.
> **Tip:** The model was fine-tuned on CelebAMask-HQ, a set of close-up face photos. It works best when the face fills a good part of the frame. For a small face in a large photo, crop around the face first.
> **Tip:** The model was fine-tuned on CelebAMask-HQ, a set of close-up face photos, so the node parses each face it finds on a close-up crop. A small face in a large photo, a half-body shot or a group photo needs no cropping first. A face the detector misses (very small, hidden or turned away) keeps the whole-picture parse, which is coarser: crop around it yourself when you need its finest detail.
> **Licence:** the face-parsing model is for non-commercial research and education only. See [Before you start](#before-you-start).
---
## 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.
@@ -209,7 +421,7 @@ my_models:
Only the PyTorch checkpoint `sam3.pt` works. The safetensors export on Hugging Face is made for the transformers library and uses other weight names.
**Memory.** The weights are read from disk once and kept in system memory (about 3.6 GB). They move to the GPU while SAM3 Segment runs and go back afterwards, so ComfyUI gets its VRAM back. Before they move, the node asks ComfyUI to unload some of its own models if VRAM is tight. Turn on `keep_model_loaded` to leave them on the GPU when you run the node again and again.
**Memory.** The weights are read from disk once and kept in system memory (about 3.6 GB). They move to the GPU while SAM3 Segment runs and go back afterwards, so ComfyUI gets its VRAM back. Before they move, the node asks ComfyUI to unload some of its own models if VRAM is tight. Turn on `keep_model_loaded` to leave them on the GPU when you run the node again and again. While it is on, ComfyUI's **Unload Models** button does not release them: turn `keep_model_loaded` off and run the node once, or restart ComfyUI.
> **Licence:** SAM 3 is released under Meta's SAM License. It is royalty-free and has no non-commercial clause, but it forbids uses that fall under ITAR or that trade controls prohibit, including military or warfare, nuclear, espionage, and guns or illegal weapons. NH-Nodes' own code is MIT, and it contains neither the model nor the `sam3` package. Read [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) before you use the node commercially.
@@ -236,55 +448,129 @@ 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 concept you wrote), 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 of your concepts, and by score within a concept.
- **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`, 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, 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. |
| `a - b` | a without b. Put spaces around the minus: `person-hair` is read as one label. |
| `a & b` | Overlap: only where a and b both are. |
| `~a`, `!a` | Everything except a: the whole picture minus a. |
| `( … )` | Grouping. |
| `background` | Everything outside every instance that was found. The same as `~all`. |
| `all` | Every instance that was found. |
| `image` | The whole picture. |
| `none` | An empty mask. |
| `# …` | A comment, to the end of the line. It starts where a word could start; inside a word, as in `car#2`, the `#` belongs to the word. |
| `"salt & pepper"` | Quotes keep together a label that contains operator characters. A quoted `all`, `image`, `none` or `background` is an ordinary label. |
`~` binds first, then `&`, then `+`, `|` and `-` together, left to right, and last the list: `a - b + c` is `(a - b) + c`. When in doubt, add parentheses.
**Line breaks.** A line break lists, like a comma. It joins instead when it is next to an operator (the line before ends with one, other than `)`, or the next line starts with `+`, `|`, `-`, `&` or `)`), so a long formula can be laid out over several lines:
```text
(wall + ceiling)
- window
```
**Vietnamese words.** SAM 3 reads English only, so the nodes translate the common Vietnamese words you type: `tóc` is `hair`, `váy` is `dress`, `cửa sổ` is `window`. The list covers people, body, clothes, accessories, nature, rooms and buildings, vehicles, food and animals. The full list is in `core/vi_words.py`.
- A thing followed by colours or sizes is put into English order: `áo đỏ` is `red top`, `tóc dài` is `long hair`, `ghế sofa lớn` is `large sofa`. `màu` (colour) may stand between them: `áo màu đỏ`. `tóc vàng` is `blonde hair`.
- `xanh` after a thing means blue or green, so it is not guessed. Write `xanh dương` (blue) or `xanh lá` (green), as in `áo xanh dương`, or write the English word.
- `các`, `những`, `mấy`, `cái`, `chiếc` and `con` in front of a thing are skipped when the phrase is not in the list as written: `những chiếc xe` is `vehicle`.
- `nền`, `phông`, `phông nền` and `hậu cảnh` are the keyword `background`, also with a colour (`nền trắng`). `tất cả` is `all`, and `toàn ảnh` and `cả ảnh` are `image`.
- Write Vietnamese with its accents. Text without Vietnamese accents is taken as English (a few short words, such as `xe`, are in the list as written).
- A Vietnamese phrase that is not in the list is searched for as written, and SAM 3 usually finds nothing. The report then says that it was not understood. The labels of the instances are always the English words.
- Quotes do not stop the translation. A quoted `nền` is the label `background`, not the keyword.
**Where the two nodes differ**
| | SAM3 Segment, `concepts` | Segs Compose, `expression` |
|---|---|---|
| `or`, `and`, `not` | Plain words, so a phrase such as `black and white dog` reaches SAM 3 as written. | Operators: `a or b`, `a and b`, `not a`. |
| Wildcards `*` and `?` | Refused: SAM 3 needs plain words. | Match labels (`*shirt`). |
| 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)
<p align="center"><img src="../assets/nodes/NH_SAM3Segment.svg" alt="SAM3 Segment (NH) node" width="360"></p>
Finds every instance of the things you name and returns them as `NH_SEGS`, together with their combined mask and a count. To see what it found, connect [Segs Preview](#segs-preview-nh).
Type what you want to cut out and get its mask: one word (`person`), a list (`hair, face`) or a formula (`person - bag`, `background`). SAM 3 finds every instance of the things you name. The node returns the mask of your formula, the instances as `NH_SEGS`, a count and a report of what was and was not found, and it shows what it found on itself.
**Use it when** you want masks of "all the people", "every window", "the sky" or "the wine glasses" in a photo, also a 4K to 8K one, without painting anything. Then use the [Segs nodes](#segs-select-nh) to pick instances, subtract one from another, or turn them into plain masks.
**Use it when** you want masks of "all the people", "every window", "the sky", "the background" or "the person without the bag" in a photo, also a 4K to 8K one, without painting anything. For finer picks (the second person from the left, the biggest object), or to make several masks from one run, connect `segs` to the [Segs nodes](#segs-select-nh).
Combo inputs without a stated default start on their first option.
| Input | Type | Default | Description |
|---|---|---|---|
| `image` | IMAGE | — | The picture. Colour (RGB) is used and any alpha is dropped. Every image of a batch is processed in turn. |
| `concepts` | STRING | `person` | What to find: short English noun phrases, one per line or separated by commas (`person, red car, window, sky`). Lines that start with `#` are ignored. Each phrase becomes the label of the instances it finds. Write at least one. |
| `threshold` | FLOAT | `0.5` | Range 0.05 to 0.95. The minimum confidence. Lower finds more instances and more mistakes; higher keeps only the sure ones. |
| `concepts` | STRING | `person` | What to find and which mask to return, written as a formula (see [Writing a formula](#writing-a-formula)): short English words (`person`, `red car`, `window`), commas or new lines to list several, and `+`, `-`, `&`, `~`, parentheses and `background` to combine them (`person - bag`). Common Vietnamese words work too (`tóc`, `áo đỏ`, `nền`). Write at least one word. |
| `threshold` | FLOAT | `0.5` | Range 0.05 to 0.95. The minimum confidence. Lower finds more instances and more mistakes; higher keeps only the sure ones. A score exactly at the threshold is not kept. When a concept finds nothing, the report says how close the best candidate came. |
| `detail_level` | COMBO | `balanced` | `balanced` or `fast`. `balanced` segments every instance a second time on a close-up of the full-resolution image, for crisp edges on small and medium objects. `fast` keeps the first, whole-image pass. See [How the two passes work](#how-the-two-passes-work). |
| `max_per_concept` | INT | `0` | Range 0 to 200. Keep at most this many instances of each concept, best score first. `0` keeps all. |
| `checkpoint` | COMBO | — | A SAM 3 `.pt` or `.pth` file from `models/sam3` or another folder registered as `sam3`. Other SAM 3 packs use the same folder. When it holds no file, the list shows `sam3.pt` and the node tells you where to get it when you run it. |
| `keep_model_loaded` | BOOLEAN | `false` | Off: after the run the weights go back to system memory and ComfyUI gets the VRAM back. On: they stay on the GPU (about 3.6 GB), which is faster when the node runs again, but ComfyUI cannot use that VRAM meanwhile. |
| `keep_model_loaded` | BOOLEAN | `false` | Off: after the run the weights go back to system memory and ComfyUI gets the VRAM back. On: they stay on the GPU (about 3.6 GB), which is faster when the node runs again, but ComfyUI cannot use that VRAM meanwhile, and its **Unload Models** button does not release it (turn this off and run the node once, or restart ComfyUI). |
| `show_preview` | BOOLEAN | `true` | Optional. Shows the result on the node: the mask bright and the rest darkened, the instances 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 below). |
| Output | Type | Description |
|---|---|---|
| `segs` | NH_SEGS | The instances of every image, each with its label, score, box and mask. Connect it to the Segs nodes. |
| `mask` | MASK | The union of every instance, one mask per image, at the size of the image. |
| `count` | INT | The number of instances found, over the whole batch. |
| `segs` | NH_SEGS | The instances of every image, each with its label, score, box and mask. The labels are the English words SAM 3 was asked for. When the formula uses `background`, they include the `person` instances that were searched for it. Connect it to the Segs nodes. |
| `mask` | MASK | The mask of your formula, one per image, at the size of the image. A plain list of words gives the union of every instance. |
| `count` | INT | The number of instances found, over the whole batch, including the `person` instances that `background` searched for. |
| `report` | STRING | What the request found, one line per word: counts and best scores, translations, near misses, words that were not understood, and what `background` meant. See below. |
<p align="center"><img src="../assets/examples/sam3-segment.jpg" alt="SAM3 Segment instances drawn by Segs Preview, and three masks built from them on a sample photo" width="720"><br><em>SAM3 Segment on a sample photo with the concepts "person, helmet, face", drawn by Segs Preview, then three masks made from the instances with Segs Compose and Segs Select.</em></p>
<p align="center"><img src="../assets/examples/sam3-formula.jpg" alt="What SAM3 Segment shows on the node for four formulas on a sample photo: background, person - face, hair and face, and everything except the person and the helmets" width="720"><br><em>SAM3 Segment on a sample photo, as the node shows it. Top: "background" lights up everything outside the person, and the report is under the picture. Bottom: three more formulas; the bright part is each one's mask output.</em></p>
**The report.** The first line of `report` gives the totals. Each line after it (they are also written under the preview) starts with what you wrote:
| Line | Meaning |
|---|---|
| `person: 2 found (best 0.91)` | The number of instances found and the best score. |
| `áo đỏ -> red top: 1 found (best 0.64)` | What a Vietnamese phrase was translated to. |
| `hair: none at threshold 0.50; the best candidate scored 0.31, lower the threshold to keep it` | Nothing passed `threshold`, but a candidate came close. Lower `threshold` below its score to keep it. |
| `bag: none found; try another English word` | No candidate scored even 0.1. |
| `áo xanh: not understood; write it in English (SAM 3 reads English only)` | A Vietnamese phrase that is not in the list and found nothing. |
| `background = everything outside person, red top` | What `background` meant: everything outside the instances of these labels. |
| `person (searched for background): 2 found` | The `person` search that the node added because the formula uses `background` and does not name `person`. |
**How it works**
- **Labels.** The concept you wrote is the label of its instances. The Segs nodes ignore case when they match labels, so spelling is not a problem.
- **Order and duplicates.** Instances come in the order of your concepts, best score first within a concept. Two instances of the same concept 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.
- **Seeing the result.** Connect `segs` and the same picture to [Segs Preview](#segs-preview-nh): every instance is tinted, outlined and labelled with its label and score.
- **Nothing found** is not an error: you get no instances, an empty mask and a `count` of 0.
- **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. 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.
- **Batches.** Every image is processed in turn and has its own list of instances. A progress bar counts the concepts, and you can cancel the run between concepts and between close-ups.
- **Things cut by the frame.** SAM 3 fades its masks over a band along the border of the square view it works in, about 0.3% of the picture's side wide. The mask of a thing that runs off the picture still reaches the image edge: the band is lifted where the object reaches it from inside. The price is that a thing which ends within that band just above the edge is drawn down to the edge.
- **Very wide or tall pictures.** SAM 3 first sees the whole picture squeezed into a square. On a picture wider or taller than 4:1 (a panorama), it can miss small things, and the console says so. Split such a picture into tiles with [Image Tile (NH)](./image-compose.md#image-tile-nh) and run the tiles.
- **Setup problems** stop the node with a message that says what to do: a missing package, a missing checkpoint, no CUDA GPU. See [If something goes wrong with SAM 3](#if-something-goes-wrong-with-sam-3).
> **Tip:** Write simple English nouns: `window`, `wine glass`, `red car`. If a concept finds nothing, try another word (an unusual building may answer to `structure` or `monument` rather than `building`) or lower `threshold`.
> **Tip:** Write simple English nouns: `window`, `wine glass`, `red car`. If a concept finds nothing, try another word (an unusual building may answer to `structure` or `monument` rather than `building`) or lower `threshold`. The report says which case it is: nothing found, a candidate that came close, or a word that was not understood.
> **Tip:** Ask for a whole and its parts together (`person, hair, face, dress`), then take them apart with [Segs Compose](#segs-compose-nh): `person - hair`.
> **Tip:** Ask for a whole and its parts in one formula: `person - hair`. The node looks for both and `mask` is the difference. `segs` still holds both, so [Segs Compose](#segs-compose-nh) can make other masks from the same run.
> **Licence:** SAM 3 is under Meta's SAM License. See [Before you start with SAM 3](#before-you-start-with-sam-3).
@@ -300,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`. 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. |
@@ -345,50 +631,65 @@ 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 a small formula: 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 want "the background", "the person without the hair", "the wall without the windows", or any other combination of the things SAM3 Segment found.
**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. |
| `expression` | STRING | `all` | The formula. See below. The field has several lines, and a line break counts as a space. |
| `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 |
|---|---|---|
| `mask` | MASK | The result of the formula, one mask per image, at the size of the image. |
**What you can write**
**What is different here.** The language is the one in [Writing a formula](#writing-a-formula), with these differences from SAM3 Segment:
| Write | Meaning |
|---|---|
| `person` | A label: the union of every instance with that label. Case does not matter, `_` counts as a space, and `*` and `?` are wildcards (`*shirt`). |
| `"red car"` | Put a label that contains spaces or an operator character in quotes, `"` or `'`, or write the space as `_` (`red_car`). Without quotes, `red car` is an error. Quotes also turn a word such as `all` or `image` into an ordinary label. |
| `t-shirt` | A hyphen between two letters or digits belongs to the label, so `t-shirt` and `x-ray` need no quotes. An apostrophe inside a word is fine too (`women's`). A label with a space needs quotes, and double quotes when it also has an apostrophe: `"women's dress"`. |
| `a \| b`, `a + b`, `a or b` | Union: everything in a or in b. |
| `a - b` | a without b. Put spaces around the minus: `person-hair` is read as one label. When both `person` and `hair` were found, the node stops with an error that shows `person - hair`; otherwise the unknown label gives an empty part and the console names it. |
| `a & b`, `a and b` | Overlap: only where a and b both are. |
| `~a`, `!a`, `not a` | Everything except a: the whole picture minus a. |
| `( … )` | Grouping. |
| `all` | Every instance in the segs. |
| `image` | The whole picture. |
| `none` | An empty mask. |
`~` binds first, then `&`, then `|` and `-` together, left to right: `a - b | c` is `(a - b) | c`. When in doubt, add parentheses.
- `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, 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`.
| Expression | You get |
|---|---|
| `person - hair` | The people without their hair (ask SAM3 Segment for `person, hair`). |
| `~person` | Everything except the people: the background. |
| `~person` | Everything except the people. |
| `background` | Everything outside every instance in the segs. Unlike `~person`, it also leaves out windows, chairs or anything else that was found. |
| `(wall \| ceiling) - window` | The walls and the ceiling with the window openings cut out. |
| `"wine glass" \| person` | The glasses and the people together. |
| `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.
- **A formula that cannot be read** stops the node with a message that gives the position of the problem, for example `unexpected 'car' at position 5`.
- **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>
---
@@ -402,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 |
|---|---|---|
@@ -425,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. |
@@ -446,7 +747,7 @@ Converts the instances of one image to Impact Pack's `SEGS`, so that its detaile
Draws the instances on their picture: each one tinted in its own colour, outlined and labelled with its label and score.
**Use it when** you want to see what SAM3 Segment found, or what Segs Select or Segs Compose works with. Put it after SAM3 Segment to see everything, or after Segs Select to see only the selection.
**Use it when** you want to see the instances at the full size of your picture, or to see what Segs Select picked. SAM3 Segment already shows a small preview on itself (at most 1536 px, with its report). Put this node after SAM3 Segment to see everything, or after Segs Select to see only the selection.
| Input | Type | Default | Description |
|---|---|---|---|
@@ -463,18 +764,17 @@ The colours follow the order of the instances, each label sits above the highest
## SAM 3 workflows
In each of these, check what SAM3 Segment found before you edit anything. Connect the same picture and the `segs` to [Segs Preview (NH)](#segs-preview-nh): every instance you want should have an outline and a label. If one is missing, lower `threshold` (for example to 0.3) or try another word for the concept.
In each of these, check what SAM3 Segment found before you edit anything. Its preview on the node shows every instance with an outline and a label, and its report says what was not found. If an instance you want is missing, lower `threshold` (for example to 0.3) or try another word for the concept.
### The background behind people
```text
Load Image → SAM3 Segment (concepts: person)
→ Segs Compose (expression: ~person)
Load Image → SAM3 Segment (concepts: background)
→ Mask Morphology (NH)
→ edit the background
```
`~person` is the whole picture except the people. Add what people hold or sit on to both fields, for example the concepts `person, wine glass, chair` and the expression `~(person | "wine glass" | chair)`. Move the edge of the mask a few pixels with [Mask Morphology (NH)](./mask.md#mask-morphology-nh): grow it so that the edit covers the old outline, or shrink it if the edit eats into the people. Then use the mask to blur, relight, recolour or inpaint the background.
`background` is everything outside the people that SAM 3 found: the node looks for `person` on its own. When people hold or sit on things, name those too, so that they stay out of the background, for example the concepts `~(person + wine glass + chair)`. Move the edge of the mask a few pixels with [Mask Morphology (NH)](./mask.md#mask-morphology-nh): grow it so that the edit covers the old outline, or shrink it if the edit eats into the people. Then use the mask to blur, relight, recolour or inpaint the background.
### The biggest object, inpainted
@@ -490,12 +790,11 @@ With `labels` empty, Segs Select picks the biggest of everything you asked for.
### Architecture: a facade without windows
```text
Load Image → SAM3 Segment (concepts: building, window, door)
→ Segs Compose (expression: building - (window | door))
Load Image → SAM3 Segment (concepts: building - (window + door))
→ recolour, restyle or inpaint the wall
```
`building` covers the whole facade with its windows and doors; the formula cuts them out and leaves the wall. For a room, ask for `wall, ceiling, window` and use `(wall | ceiling) - window`. If `building` finds nothing on an unusual building, try `structure` or `monument` and change the formula to match. On a 4K to 8K photo, keep `balanced`: every window is segmented again on a close-up of the original pixels. The outline of a facade that fills the whole frame is accurate to about 1/300 of its width (see [How the two passes work](#how-the-two-passes-work)), so check the roofline in Segs Preview.
`building` covers the whole facade with its windows and doors; the formula cuts them out and leaves the wall. For a room, use `(wall + ceiling) - window`. If `building` finds nothing on an unusual building, try `structure` or `monument` and change the formula to match. On a 4K to 8K photo, keep `balanced`: every window is segmented again on a close-up of the original pixels. The outline of a facade that fills the whole frame is accurate to about 1/300 of its width (see [How the two passes work](#how-the-two-passes-work)), so check the roofline in the preview.
---
@@ -509,15 +808,24 @@ Load Image → SAM3 Segment (concepts: building, window, door)
| An error that a file is not a SAM 3 `.pt` checkpoint | Use `sam3.pt`. The safetensors files on Hugging Face do not work with this node. |
| An error that a file cannot be read as a SAM 3 checkpoint, or is not one | The file in the `sam3` folder is an incomplete download or another model's `.pt`. Download `sam3.pt` again as in [Before you start with SAM 3](#before-you-start-with-sam-3). |
| An error that SAM 3 needs an NVIDIA GPU with CUDA | ComfyUI is running on the CPU or on another kind of GPU. SAM3 Segment has no CPU mode. |
| An error that you must write at least one concept | `concepts` is empty or holds only comments. Write a word such as `person`. |
| `count` is 0, or an instance is missing | Lower `threshold`, try another word for the concept, and look at the result with Segs Preview. |
| An error that you must write at least one concept | `concepts` is empty, holds only comments, or holds only the keywords `all`, `image` or `none`. Write a word such as `person`. |
| An error that a concept has a wildcard | Remove the `*` or `?`: SAM 3 needs plain words. Wildcards work in Segs Select and Segs Compose. |
| An error from SAM3 Segment that gives a position, such as `unexpected '(' at position 8` | Check the formula at that position. A bracket in a label is read as grouping. See [Writing a formula](#writing-a-formula). |
| `count` is 0, or an instance is missing | Read the report: it says whether the word found nothing, a candidate came close (lower `threshold` to its score), or the word was not understood (write it in English). The preview on the node shows what was found. |
| The report says a word was not understood | It is a Vietnamese phrase that is not in the list. Write it in English. After a thing, `xanh` is ambiguous: write `xanh dương` (blue) or `xanh lá` (green). |
| The mask is the whole picture, or not what you expected | A list is a union: `person, background` is the person plus everything outside it. Write `background` alone, or a formula such as `person - bag`, and look at the preview: the bright part is the mask. |
| SAM3 Segment runs although nothing is connected to it | It is an output node. Mute or delete it when you do not need it; turning `show_preview` off does not stop it. |
| The console says it kept the first-pass mask for some instances | Their close-up did not match the first pass, so those instances keep the first pass's edges. Nothing is wrong. |
| 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. Put labels with spaces in quotes: `"red car"`. |
| 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`. |
| 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, 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. |
| ComfyUI's Unload Models button does not free the VRAM of SAM 3 | `keep_model_loaded` is on. Turn it off and run the node once, or restart ComfyUI. |
---
+15 -11
View File
@@ -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 từng instance của bất cứ
thứ gì bạn gọi tên bằng SAM 3, 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.
+34 -9
View File
@@ -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**
+2 -1
View File
@@ -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, ghép lại bằng [Segs Compose (NH)](./vision.md#segs-compose-nh)), [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.
---
@@ -172,6 +172,7 @@ Portrait Segment (NH) trên ảnh đích → NH Simple Face Paste.dest_mask
- Dùng các mask mà hộp bao quanh có tỉ lệ rất khác nhau. Khuôn mặt trông bị bẹp hoặc bị kéo dài.
- Đưa vào mask có kích thước khác ảnh của nó.
- Dùng mask của [Portrait Segment (NH)](./vision.md#portrait-segment-nh) trên một ảnh có nhiều khuôn mặt. Mask chứa mọi khuôn mặt, nên hộp bao quanh nó phủ cả chúng.
- Đặt `feathering` rất cao cho một khuôn mặt nhỏ. Phép làm mờ có thể nuốt mất cả hình dạng.
> **Mẹo:** Hãy làm `dest_mask` lớn hơn một chút so với khuôn mặt bạn thay, và giữ `source_mask` sát khuôn mặt. Khi đó biên mềm của mask nguồn hòa vào ảnh đích.
+421 -113
View File
@@ -2,23 +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: SAM3 Segment tìm mọi instance (từng đối tượng riêng lẻ) của bất cứ thứ gì bạn gọi tên bằng tiếng Anh thông thường (người, ly rượu, cửa sổ, bầu trời), và năm node Segs giúp chọn, ghép, xem trước và chuyển đổi những gì nó tìm được. 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**
- [Trước khi bắt đầu](#trước-khi-bắt-đầu) — model, lần chạy đầu tiên, giấy phép, bộ 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
- [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, 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
- [SAM3 Segment (NH)](#sam3-segment-nh) — tìm mọi instance của các concept bạn gọi tên
- [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
@@ -29,43 +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: áo, quần, giày, mũ | [Garment Segment](#garment-segment-nh) | Một model SegFormer 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 | [Portrait Segment](#portrait-segment-nh) | Giống như trên. |
| 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ổ), 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). |
| 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. 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ớ.** 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ậ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.
> **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ề.
**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.
> **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. 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.
> **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:** 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á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 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: 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.
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ả |
|---|---|---|
@@ -81,43 +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 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. |
| `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. |
| `image` | IMAGE | — | Ảnh chụp một người hoặc một nhóm người. |
| `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.
- **Đầ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ụ**
@@ -127,7 +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ũ.
> **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).
> **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:** 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).
---
@@ -135,13 +177,13 @@ Hãy mở rộng mask thêm vài pixel bằng [Mask Morphology (NH)](./mask.md#m
<p align="center"><img src="../assets/nodes/NH_PortraitSegment.svg" alt="Portrait Segment (NH) node" width="360"></p>
Tạo mask cho 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. Bạn bật những vùng muốn lấy.
Tạo mask cho 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, hoặc của mọi khuôn mặt trong ảnh nhóm. Bạn bật những vùng muốn lấy.
**Dùng khi** bạn muốn chỉnh sửa hoặc đổi màu tóc, xử lý da và các bộ phận trên mặt, hoặc bảo vệ khuôn mặt trong khi chỉnh phần còn lại của ảnh.
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `image` | IMAGE | — | Một ảnh chân dung. |
| `image` | IMAGE | — | Một ảnh chân dung, ảnh nửa người hoặc ảnh nhóm. |
| `face` | BOOLEAN | `true` | Vùng khuôn mặt đặc: da, mũi, mắt, lông mày, kính mắt, môi và bên trong miệng. |
| `neck` | BOOLEAN | `true` | Cổ. |
| `hair` | BOOLEAN | `true` | Tóc. |
@@ -158,15 +200,20 @@ Tạo mask cho khuôn mặt, tóc, cổ và các bộ phận trên mặt của m
| Đầu ra | Kiểu | Mô tả |
|---|---|---|
| `mask` | MASK | Mask gộp của mọi vùng đã bật. |
| `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.
- **Khuôn mặt lớn.** Khuôn mặt đã chiếm một phần lớn khung hình thì được giao cho lượt phân tích cả bức ảnh, vì lượt đó đã thấy nó đủ 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).
<p align="center"><img src="../assets/examples/portrait-segment.jpg" alt="Mask của Portrait Segment phủ lên một ảnh mẫu" width="720"><br><em>Portrait Segment trên một ảnh mẫu: các vùng tô màu là mask của những vùng đã bật.</em></p>
@@ -178,12 +225,177 @@ Load Image → Portrait Segment (NH) → mask → đổi màu hoặc inpaint
Tắt mọi vùng trừ `hair`. Với các giá trị mặc định, bạn có khuôn mặt, tai, cổ và tóc cùng trong một mask.
> **Mẹo:** Model được tinh chỉnh trên CelebAMask-HQ, một bộ ảnh chụp cận mặt. Nó hoạt động tốt nhất khi khuôn mặt chiếm một phần đáng kể của khung hình. Với khuôn mặt nhỏ trong ảnh lớn, hãy cắt (crop) quanh khuôn mặt trước.
> **Mẹo:** Model được tinh chỉnh trên CelebAMask-HQ, một bộ ảnh chụp cận mặt, nên node phân tích mỗi khuôn mặt tìm được trên một vùng cắt cận cảnh. Một khuôn mặt nhỏ trong ảnh lớn, một ảnh nửa người hay một ảnh nhóm không cần cắt (crop) trước. Khuôn mặt mà bộ nhận diện bỏ sót (rất nhỏ, bị che hoặc quay đi) giữ kết quả của lượt phân tích cả bức ảnh, thô hơn: hãy tự cắt quanh nó khi cần chi tiết mịn nhất.
> **Giấy phép:** model face-parsing chỉ dành cho nghiên cứu và giáo dục phi thương mại. Xem [Trước khi bắt đầu](#trước-khi-bắt-đầu).
---
## 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.
@@ -209,7 +421,7 @@ my_models:
Chỉ checkpoint PyTorch `sam3.pt` dùng được. Bản safetensors trên Hugging Face dành cho thư viện transformers và dùng tên trọng số khác.
**Bộ nhớ.** Trọng số được đọc từ đĩa một lần và giữ trong bộ nhớ hệ thống (khoảng 3.6 GB). Chúng chuyển lên GPU khi SAM3 Segment chạy và trở về sau đó, nên ComfyUI nhận lại VRAM. Trước khi chuyển, node nhờ ComfyUI gỡ bớt các model của chính nó nếu VRAM đang eo hẹp. Hãy bật `keep_model_loaded` để trọng số ở lại trên GPU nếu bạn chạy node liên tục.
**Bộ nhớ.** Trọng số được đọc từ đĩa một lần và giữ trong bộ nhớ hệ thống (khoảng 3.6 GB). Chúng chuyển lên GPU khi SAM3 Segment chạy và trở về sau đó, nên ComfyUI nhận lại VRAM. Trước khi chuyển, node nhờ ComfyUI gỡ bớt các model của chính nó nếu VRAM đang eo hẹp. Hãy bật `keep_model_loaded` để trọng số ở lại trên GPU nếu bạn chạy node liên tục. Khi nó bật, nút **Unload Models** của ComfyUI không giải phóng chúng: hãy tắt `keep_model_loaded` và chạy node một lần, hoặc khởi động lại ComfyUI.
> **Giấy phép:** SAM 3 được phát hành theo SAM License của Meta. Giấy phép này miễn phí bản quyền (royalty-free) và không có điều khoản cấm thương mại, nhưng cấm những mục đích thuộc ITAR hoặc bị kiểm soát thương mại (trade controls) cấm, gồm quân sự hay chiến tranh, hạt nhân, gián điệp, súng và vũ khí bất hợp pháp. Mã nguồn của NH-Nodes thuộc giấy phép MIT và không chứa model lẫn gói `sam3`. Hãy đọc [THIRD_PARTY_NOTICES.md](../../THIRD_PARTY_NOTICES.md) trước khi dùng node cho mục đích thương mại.
@@ -236,55 +448,129 @@ 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** (concept bạn đã viết), **đ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ự concept bạn viết, và theo điểm trong cùng một concept.
- **Đố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, `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, 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. |
| `a - b` | a bỏ đi phần của b. Hãy đặt dấu cách hai bên dấu trừ: `person-hair` được đọc là một nhãn duy nhất. |
| `a & b` | Giao: chỉ nơi cả a và b cùng có. |
| `~a`, `!a` | Mọi thứ trừ a: cả bức ảnh bỏ đi a. |
| `( … )` | Nhóm. |
| `background` | Mọi thứ nằm ngoài mọi instance đã tìm được. Giống `~all`. |
| `all` | Mọi instance đã tìm được. |
| `image` | Cả bức ảnh. |
| `none` | Một mask rỗng. |
| `# …` | Ghi chú, đến hết dòng. Nó bắt đầu ở nơi một từ có thể bắt đầu; nằm giữa một từ, như `car#2`, thì `#` thuộc về từ đó. |
| `"salt & pepper"` | Ngoặc kép giữ chung một nhãn có chứa ký tự toán tử. Một từ `all`, `image`, `none` hay `background` đặt trong ngoặc kép là nhãn thông thường. |
`~` được tính trước, rồi `&`, rồi `+`, `|` và `-` cùng cấp, từ trái sang phải, cuối cùng là danh sách: `a - b + c` nghĩa là `(a - b) + c`. Khi còn phân vân, hãy thêm dấu ngoặc.
**Xuống dòng.** Xuống dòng có tác dụng liệt kê, như dấu phẩy. Nó nối liền khi nằm cạnh một toán tử (dòng trước kết thúc bằng một toán tử, trừ `)`, hoặc dòng sau bắt đầu bằng `+`, `|`, `-`, `&` hay `)`), nên một công thức dài có thể được trình bày trên nhiều dòng:
```text
(wall + ceiling)
- window
```
**Từ tiếng Việt.** SAM 3 chỉ đọc tiếng Anh, nên các node dịch những từ tiếng Việt thông dụng bạn gõ: `tóc` là `hair`, `váy` là `dress`, `cửa sổ` là `window`. Danh sách gồm con người, cơ thể, quần áo, phụ kiện, thiên nhiên, phòng và công trình, phương tiện, đồ ăn và động vật. Danh sách đầy đủ nằm trong `core/vi_words.py`.
- Một thứ đi kèm màu sắc hoặc kích cỡ được đảo sang thứ tự của tiếng Anh: `áo đỏ` là `red top`, `tóc dài` là `long hair`, `ghế sofa lớn` là `large sofa`. `màu` có thể nằm giữa chúng: `áo màu đỏ`. `tóc vàng` là `blonde hair`.
- `xanh` đứng sau một thứ có thể là xanh dương hoặc xanh lá nên không được đoán. Hãy viết `xanh dương` (blue) hoặc `xanh lá` (green), như `áo xanh dương`, hoặc viết từ tiếng Anh.
- `các`, `những`, `mấy`, `cái`, `chiếc` và `con` đứng trước một thứ bị bỏ qua khi cụm từ không có trong danh sách đúng như đã viết: `những chiếc xe` là `vehicle`.
- `nền`, `phông`, `phông nền` và `hậu cảnh` là từ khóa `background`, cả khi có kèm màu (`nền trắng`). `tất cả` là `all`, còn `toàn ảnh` và `cả ảnh` là `image`.
- Hãy viết tiếng Việt có dấu. Văn bản không có dấu tiếng Việt được coi là tiếng Anh (một vài từ ngắn như `xe` có trong danh sách đúng như đã viết).
- Một cụm tiếng Việt không có trong danh sách sẽ được tìm đúng như đã viết, và SAM 3 thường không tìm thấy gì. Khi đó báo cáo ghi là không hiểu. Nhãn của các instance luôn là từ tiếng Anh.
- Ngoặc kép không chặn việc dịch. `nền` trong ngoặc kép là nhãn `background`, không phải từ khóa.
**Hai node khác nhau ở đâu**
| | SAM3 Segment, `concepts` | Segs Compose, `expression` |
|---|---|---|
| `or`, `and`, `not` | Là từ thông thường, nên một cụm như `black and white dog` đến với SAM 3 đúng như đã viết. | Là toán tử: `a or b`, `a and b`, `not a`. |
| Ký tự đại diện `*` và `?` | Bị từ chối: SAM 3 cần từ thông thường. | Khớp với nhãn (`*shirt`). |
| 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)
<p align="center"><img src="../assets/nodes/NH_SAM3Segment.svg" alt="SAM3 Segment (NH) node" width="360"></p>
Tìm mọi instance của những thứ bạn gọi tên và trả về chúng dưới dạng `NH_SEGS`, kèm mask gộp của chúng và số lượng. Muốn xem nó tìm được gì, hãy nối [Segs Preview](#segs-preview-nh).
Gõ thứ bạn muốn cắt ra và nhận mask của nó: một từ (`person`), một danh sách (`hair, face`) hoặc một công thức (`person - bag`, `background`). SAM 3 tìm mọi instance của những thứ bạn gọi tên. Node trả về mask của công thức, các instance dưới dạng `NH_SEGS`, một con số đếm và một báo cáo cho biết đã tìm thấy và chưa tìm thấy gì, đồng thời hiển thị những gì nó tìm được ngay trên node.
**Dùng khi** bạn muốn có mask của "mọi người", "mọi cửa sổ", "bầu trời" hay "các ly rượu" trong một bức ảnh, kể cả ảnh 4K đến 8K, mà không phải vẽ gì. Sau đó dùng các [node Segs](#segs-select-nh) để chọn instance, trừ cái này khỏi cái kia, hoặc đổi chúng thành mask thông thường.
**Dùng khi** bạn muốn có mask của "mọi người", "mọi cửa sổ", "bầu trời", "phần nền" hay "người nhưng không có túi" trong một bức ảnh, kể cả ảnh 4K đến 8K, mà không phải vẽ gì. Với việc chọn tinh hơn (người thứ hai từ trái sang, vật lớn nhất), hoặc để tạo nhiều mask từ một lần chạy, hãy nối `segs` vào các [node Segs](#segs-select-nh).
Các đầu vào COMBO không nêu mặc định sẽ bắt đầu ở lựa chọn đầu tiên.
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
| `image` | IMAGE | — | Bức ảnh. Node dùng màu (RGB), kênh alpha nếu có sẽ bị bỏ. Mọi ảnh trong batch được xử lý lần lượt. |
| `concepts` | STRING | `person` | Thứ cần tìm: các cụm danh từ tiếng Anh ngắn, mỗi dòng một cụm hoặc cách nhau bằng dấu phẩy (`person, red car, window, sky`). Dòng bắt đầu bằng `#` bị bỏ qua. Mỗi cụm trở thành nhãn của các instance nó tìm được. Hãy viết ít nhất một cụm. |
| `threshold` | FLOAT | `0.5` | Khoảng 0.05 đến 0.95. Độ tin cậy tối thiểu. Thấp hơn thì tìm được nhiều instance hơn và cũng nhiều nhầm lẫn hơn; cao hơn chỉ giữ những cái chắc chắn. |
| `concepts` | STRING | `person` | Thứ cần tìm và mask cần trả về, viết dưới dạng công thức (xem [Viết công thức](#viết-công-thức)): các từ tiếng Anh ngắn (`person`, `red car`, `window`), dấu phẩy hoặc xuống dòng để liệt kê nhiều thứ, và `+`, `-`, `&`, `~`, dấu ngoặc cùng `background` để kết hợp chúng (`person - bag`). Các từ tiếng Việt thông dụng cũng dùng được (`tóc`, `áo đỏ`, `nền`). Hãy viết ít nhất một từ. |
| `threshold` | FLOAT | `0.5` | Khoảng 0.05 đến 0.95. Độ tin cậy tối thiểu. Thấp hơn thì tìm được nhiều instance hơn và cũng nhiều nhầm lẫn hơn; cao hơn chỉ giữ những cái chắc chắn. Điểm đúng bằng ngưỡng thì không được giữ. Khi một concept không tìm thấy gì, báo cáo cho biết ứng viên tốt nhất đã đến gần mức nào. |
| `detail_level` | COMBO | `balanced` | `balanced` hoặc `fast`. `balanced` phân đoạn lại mỗi instance lần thứ hai trên ảnh cận cảnh cắt từ ảnh độ phân giải đầy đủ, để vật nhỏ và vừa có cạnh sắc nét. `fast` giữ lượt đầu trên cả bức ảnh. Xem [Cách hai lượt phân tích hoạt động](#cách-hai-lượt-phân-tích-hoạt-động). |
| `max_per_concept` | INT | `0` | Khoảng 0 đến 200. Giữ tối đa bấy nhiêu instance cho mỗi concept, điểm cao nhất trước. `0` giữ tất cả. |
| `checkpoint` | COMBO | — | Một tệp `.pt` hoặc `.pth` của SAM 3 trong `models/sam3` hoặc thư mục khác đã đăng ký với tên `sam3`. Các pack SAM 3 khác dùng chung thư mục này. Khi thư mục chưa có tệp nào, danh sách hiện `sam3.pt` và node cho bạn biết lấy nó ở đâu khi bạn chạy. |
| `keep_model_loaded` | BOOLEAN | `false` | Tắt: sau lần chạy, trọng số trở về bộ nhớ hệ thống và ComfyUI nhận lại VRAM. Bật: chúng ở lại trên GPU (khoảng 3.6 GB), giúp lần chạy sau nhanh hơn, nhưng ComfyUI không dùng được phần VRAM đó trong lúc ấy. |
| `keep_model_loaded` | BOOLEAN | `false` | Tắt: sau lần chạy, trọng số trở về bộ nhớ hệ thống và ComfyUI nhận lại VRAM. Bật: chúng ở lại trên GPU (khoảng 3.6 GB), giúp lần chạy sau nhanh hơn, nhưng ComfyUI không dùng được phần VRAM đó trong lúc ấy, và nút **Unload Models** của nó không giải phóng được (hãy tắt tùy chọn này rồi chạy node một lần, hoặc khởi động lại ComfyUI). |
| `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 instance 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 bên dưới). |
| Đầu ra | Kiểu | Mô tả |
|---|---|---|
| `segs` | NH_SEGS | Các instance của từng ảnh, mỗi instance có nhãn, điểm, khung và mask. Hãy nối nó vào các node Segs. |
| `mask` | MASK | Hợp của mọi instance, một mask cho mỗi ảnh, có kích thước bằng ảnh. |
| `count` | INT | Số instance tìm được, tính trên cả batch. |
| `segs` | NH_SEGS | Các instance của từng ảnh, mỗi instance có nhãn, điểm, khung và mask. Nhãn là các từ tiếng Anh mà SAM 3 được yêu cầu tìm. Khi công thức dùng `background`, chúng bao gồm cả các instance `person` đã được tìm cho nó. Hãy nối nó vào các node Segs. |
| `mask` | MASK | Mask của công thức, một mask cho mỗi ảnh, có kích thước bằng ảnh. Một danh sách từ đơn thuần cho hợp của mọi instance. |
| `count` | INT | Số instance tìm được, tính trên cả batch, gồm cả các instance `person` mà `background` đã tìm. |
| `report` | STRING | Những gì yêu cầu đã tìm được, mỗi từ một dòng: số lượng và điểm cao nhất, bản dịch, các trường hợp suýt đạt, các từ không hiểu, và ý nghĩa của `background`. Xem bên dưới. |
<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 trên một ảnh mẫu" width="720"><br><em>SAM3 Segment trên một ảnh mẫu với các concept "person, helmet, face", do Segs Preview vẽ, rồi ba mask tạo từ các instance bằng Segs Compose và Segs Select.</em></p>
<p align="center"><img src="../assets/examples/sam3-formula.jpg" alt="Thứ SAM3 Segment hiển thị trên node với bốn công thức trên một ảnh mẫu: background, person - face, hair và face, và mọi thứ trừ người và các mũ bảo hiểm" width="720"><br><em>SAM3 Segment trên một ảnh mẫu, đúng như node hiển thị. Hàng trên: "background" làm sáng mọi thứ nằm ngoài người, và báo cáo nằm bên dưới bức ảnh. Hàng dưới: ba công thức khác; phần sáng là đầu ra mask của mỗi công thức.</em></p>
**Báo cáo.** Dòng đầu của `report` cho tổng số. Mỗi dòng sau đó (chúng cũng được viết bên dưới bản xem trước) bắt đầu bằng thứ bạn đã viết:
| Dòng | Nghĩa |
|---|---|
| `person: 2 found (best 0.91)` | Số instance tìm được và điểm cao nhất. |
| `áo đỏ -> red top: 1 found (best 0.64)` | Cụm tiếng Việt đã được dịch thành gì. |
| `hair: none at threshold 0.50; the best candidate scored 0.31, lower the threshold to keep it` | Không có gì vượt `threshold`, nhưng có một ứng viên suýt đạt. Hãy hạ `threshold` xuống dưới điểm của nó để giữ lại. |
| `bag: none found; try another English word` | Không ứng viên nào đạt nổi 0.1. |
| `áo xanh: not understood; write it in English (SAM 3 reads English only)` | Một cụm tiếng Việt không có trong danh sách và không tìm thấy gì. |
| `background = everything outside person, red top` | Ý nghĩa của `background`: mọi thứ nằm ngoài các instance của những nhãn này. |
| `person (searched for background): 2 found` | Lần tìm `person` mà node thêm vào vì công thức dùng `background` mà không nêu `person`. |
**Cách hoạt động**
- **Nhãn.** Concept bạn viết là nhãn của các instance của nó. 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 đề.
- **Thứ tự và bản trùng.** Các instance xếp theo thứ tự concept của bạn, điểm cao nhất trước trong mỗi concept. Hai instance của cùng một concept 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.
- **Xem kết quả.** Nối `segs` và đúng bức ảnh đó vào [Segs Preview](#segs-preview-nh): mỗi instance được tô màu, có viền và gắn nhãn cùng điểm của nó.
- **Không tìm thấy gì** không phải lỗi: bạn nhận được không instance nào, một mask rỗng và `count` bằng 0.
- **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. 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.
- **Batch.** Mỗi ảnh được xử lý lần lượt và có danh sách instance riêng. Thanh tiến trình đếm theo concept, và bạn có thể hủy lần chạy giữa các concept và giữa các ảnh cận cảnh.
- **Vật bị khung hình cắt.** SAM 3 làm mờ dần mask của nó trong một dải dọc biên của hình vuông nó làm việc, rộng khoảng 0.3% cạnh của bức ảnh. Mask của một vật tràn ra khỏi ảnh vẫn chạm tới mép ảnh: dải đó được nâng lên ở nơi vật chạm tới nó từ bên trong. Cái giá là một vật kết thúc trong dải đó, ngay phía trên mép ảnh, bị vẽ kéo xuống tận mép.
- **Ảnh rất rộng hoặc rất cao.** Trước hết SAM 3 nhìn cả bức ảnh bị nén vào một hình vuông. Với ảnh rộng hoặc cao hơn 4:1 (ảnh toàn cảnh), nó có thể bỏ sót vật nhỏ, và console cho biết điều đó. Hãy chia ảnh như vậy thành các ô bằng [Image Tile (NH)](./image-compose.md#image-tile-nh) rồi chạy từng ô.
- **Lỗi cài đặt** làm node dừng lại với thông báo nói rõ cần làm gì: thiếu gói, thiếu checkpoint, không có GPU CUDA. Xem [Khi có sự cố với SAM 3](#khi-có-sự-cố-với-sam-3).
> **Mẹo:** Hãy viết danh từ tiếng Anh đơn giản: `window`, `wine glass`, `red car`. Nếu một concept không tìm thấy gì, hãy thử từ khác (một tòa nhà có hình dạng lạ có thể đáp ứng `structure` hoặc `monument` thay vì `building`) hoặc hạ `threshold`.
> **Mẹo:** Hãy viết danh từ tiếng Anh đơn giản: `window`, `wine glass`, `red car`. Nếu một concept không tìm thấy gì, hãy thử từ khác (một tòa nhà có hình dạng lạ có thể đáp ứng `structure` hoặc `monument` thay vì `building`) hoặc hạ `threshold`. Báo cáo cho biết đó là trường hợp nào: không tìm thấy gì, một ứng viên suýt đạt, hay một từ không được hiểu.
> **Mẹo:** Hãy yêu cầu cả tổng thể lẫn các phần của nó (`person, hair, face, dress`), rồi tách chúng ra bằng [Segs Compose](#segs-compose-nh): `person - hair`.
> **Mẹo:** Hãy yêu cầu cả tổng thể lẫn các phần của nó trong cùng một công thức: `person - hair`. Node tìm cả hai và `mask` là phần chênh lệch. `segs` vẫn chứa cả hai, nên [Segs Compose](#segs-compose-nh) có thể tạo các mask khác từ cùng lần chạy đó.
> **Giấy phép:** SAM 3 thuộc SAM License của Meta. Xem [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3).
@@ -300,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`. Để 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. |
@@ -345,50 +631,65 @@ 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 một công thức nhỏ: 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 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 bất kỳ tổ hợp nào khác của những thứ SAM3 Segment tìm đượ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. 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. |
| `expression` | STRING | `all` | Công thức. Xem bên dưới. Ô này có nhiều dòng, và xuống dòng được coi là dấu cách. |
| `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ả |
|---|---|---|
| `mask` | MASK | Kết quả của công thức, một mask cho mỗi ảnh, có kích thước bằng ảnh. |
**Những gì bạn có thể viết**
**Ở đâ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:
| Viết | Nghĩa |
|---|---|
| `person` | Một nhãn: hợp của mọi instance mang nhãn đó. Không phân biệt chữ hoa/thường, `_` được coi là dấu cách, và `*`, `?` là ký tự đại diện (wildcard) (`*shirt`). |
| `"red car"` | Đặt nhãn có dấu cách hoặc ký tự toán tử trong ngoặc kép, `"` hoặc `'`, hoặc viết dấu cách thành `_` (`red_car`). Không có ngoặc kép, `red car` là lỗi. Ngoặc kép cũng biến một từ như `all` hay `image` thành nhãn thông thường. |
| `t-shirt` | Dấu gạch nối nằm giữa hai chữ cái hoặc chữ số thuộc về nhãn, nên `t-shirt` và `x-ray` không cần ngoặc kép. Dấu nháy đơn nằm giữa một từ cũng được (`women's`). Nhãn có dấu cách cần ngoặc kép, và dùng ngoặc kép đôi khi nhãn còn có dấu nháy đơn: `"women's dress"`. |
| `a \| b`, `a + b`, `a or b` | Hợp: mọi thứ thuộc a hoặc thuộc b. |
| `a - b` | a bỏ đi phần của b. Hãy đặt dấu cách hai bên dấu trừ: `person-hair` được đọc là một nhãn duy nhất. Khi cả `person` và `hair` đều có, node dừng với lỗi gợi ý `person - hair`; nếu không, nhãn lạ cho phần rỗng và console ghi tên nhãn đó. |
| `a & b`, `a and b` | Giao: chỉ nơi cả a và b cùng có. |
| `~a`, `!a`, `not a` | Mọi thứ trừ a: cả bức ảnh bỏ đi a. |
| `( … )` | Nhóm. |
| `all` | Mọi instance trong segs. |
| `image` | Cả bức ảnh. |
| `none` | Một mask rỗng. |
`~` được tính trước, rồi `&`, rồi `|` và `-` cùng cấp, từ trái sang phải: `a - b | c` nghĩa là `(a - b) | c`. Khi còn phân vân, hãy thêm dấu ngoặc.
- `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, 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`.
| Biểu thức | Bạn nhận được |
|---|---|
| `person - hair` | Người nhưng bỏ phần tóc (hãy yêu cầu SAM3 Segment `person, hair`). |
| `~person` | Mọi thứ trừ người: phần nền. |
| `~person` | Mọi thứ trừ người. |
| `background` | Mọi thứ nằm ngoài mọi instance trong segs. Khác với `~person`, nó cũng loại cả cửa sổ, ghế hay bất cứ thứ gì khác đã tìm được. |
| `(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. |
| `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ó.
- **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 'car' at position 5`.
- **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>
---
@@ -402,7 +703,7 @@ Tạo một mask từ nhãn của các instance bằng một 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ả |
|---|---|---|
@@ -425,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. |
@@ -446,7 +747,7 @@ Chuyển các instance của một ảnh sang `SEGS` của Impact Pack, để c
Vẽ các instance lên ảnh của chúng: mỗi instance được tô màu riêng, có viền và gắn nhãn cùng điểm của nó.
**Dùng khi** bạn muốn xem SAM3 Segment đã tìm được gì, hoặc Segs Select và Segs Compose đang làm việc với cái gì. Hãy đặt node này sau SAM3 Segment để xem tất cả, hoặc sau Segs Select để chỉ xem phần đã chọn.
**Dùng khi** bạn muốn xem các instance ở kích thước đầy đủ của bức ảnh, hoặc xem Segs Select đã chọn gì. SAM3 Segment đã hiển thị một bản xem trước nhỏ ngay trên node (tối đa 1536 px, kèm báo cáo). Hãy đặt node này sau SAM3 Segment để xem tất cả, hoặc sau Segs Select để chỉ xem phần đã chọn.
| Đầu vào | Kiểu | Mặc định | Mô tả |
|---|---|---|---|
@@ -463,18 +764,17 @@ Màu sắc theo thứ tự của các instance, mỗi nhãn nằm phía trên đ
## Workflow với SAM 3
Trong mỗi workflow dưới đây, hãy kiểm tra những gì SAM3 Segment tìm được trước khi chỉnh sửa bất cứ thứ gì. Nối đúng bức ảnh đó và `segs` vào [Segs Preview (NH)](#segs-preview-nh): mọi instance bạn muốn đều phải có viền và nhãn. Nếu thiếu một cái, hãy hạ `threshold` (ví dụ xuống 0.3) hoặc thử từ khác cho concept.
Trong mỗi workflow dưới đây, hãy kiểm tra những gì SAM3 Segment tìm được trước khi chỉnh sửa bất cứ thứ gì. Bản xem trước trên node cho thấy mọi instance với viền và nhãn, và báo cáo của nó cho biết cái gì không tìm thấy. Nếu thiếu một instance bạn muốn, hãy hạ `threshold` (ví dụ xuống 0.3) hoặc thử từ khác cho concept.
### Nền phía sau người
```text
Load Image → SAM3 Segment (concepts: person)
→ Segs Compose (expression: ~person)
Load Image → SAM3 Segment (concepts: background)
→ Mask Morphology (NH)
→ chỉnh sửa phần nền
```
`~person` là cả bức ảnh trừ những người trong đó. Hãy thêm những thứ người đang cầm hoặc ngồi lên vào cả hai ô, ví dụ concept `person, wine glass, chair` và biểu thức `~(person | "wine glass" | chair)`. Dịch cạnh của mask vài pixel bằng [Mask Morphology (NH)](./mask.md#mask-morphology-nh): mở rộng để phần chỉnh sửa phủ lên đường viền cũ, hoặc thu hẹp nếu phần chỉnh sửa lấn vào người. Sau đó dùng mask này để làm mờ, đổi ánh sáng, đổi màu hoặc inpaint phần nền.
`background` là mọi thứ nằm ngoài những người SAM 3 tìm thấy: node tự tìm `person`. Khi người đang cầm hoặc ngồi lên thứ gì, hãy nêu tên cả những thứ đó để chúng cũng nằm ngoài phần nền, ví dụ concepts `~(person + wine glass + chair)`. Dịch cạnh của mask vài pixel bằng [Mask Morphology (NH)](./mask.md#mask-morphology-nh): mở rộng để phần chỉnh sửa phủ lên đường viền cũ, hoặc thu hẹp nếu phần chỉnh sửa lấn vào người. Sau đó dùng mask này để làm mờ, đổi ánh sáng, đổi màu hoặc inpaint phần nền.
### Vật lớn nhất, rồi inpaint
@@ -490,12 +790,11 @@ Khi `labels` để trống, Segs Select chọn cái lớn nhất trong mọi th
### Kiến trúc: mặt tiền không có cửa sổ
```text
Load Image → SAM3 Segment (concepts: building, window, door)
→ Segs Compose (expression: building - (window | door))
Load Image → SAM3 Segment (concepts: building - (window + door))
→ đổi màu, đổi phong cách hoặc inpaint bức tường
```
`building` phủ cả mặt tiền cùng cửa sổ và cửa ra vào; công thức khoét chúng ra và chừa lại bức tường. Với một căn phòng, hãy yêu cầu `wall, ceiling, window` và dùng `(wall | ceiling) - window`. Nếu `building` không tìm thấy gì trên một tòa nhà có hình dạng lạ, hãy thử `structure` hoặc `monument` và sửa công thức cho khớp. Trên ảnh 4K đến 8K, hãy giữ `balanced`: mỗi cửa sổ được phân đoạn lại trên một ảnh cận cảnh của các điểm ảnh gốc. Đường viền của một mặt tiền chiếm trọn khung hình chỉ chính xác đến khoảng 1/300 bề ngang của nó (xem [Cách hai lượt phân tích hoạt động](#cách-hai-lượt-phân-tích-hoạt-động)), nên hãy kiểm tra đường mái trong Segs Preview.
`building` phủ cả mặt tiền cùng cửa sổ và cửa ra vào; công thức khoét chúng ra và chừa lại bức tường. Với một căn phòng, hãy dùng `(wall + ceiling) - window`. Nếu `building` không tìm thấy gì trên một tòa nhà có hình dạng lạ, hãy thử `structure` hoặc `monument` và sửa công thức cho khớp. Trên ảnh 4K đến 8K, hãy giữ `balanced`: mỗi cửa sổ được phân đoạn lại trên một ảnh cận cảnh của các điểm ảnh gốc. Đường viền của một mặt tiền chiếm trọn khung hình chỉ chính xác đến khoảng 1/300 bề ngang của nó (xem [Cách hai lượt phân tích hoạt động](#cách-hai-lượt-phân-tích-hoạt-động)), nên hãy kiểm tra đường mái trong bản xem trước.
---
@@ -509,15 +808,24 @@ Load Image → SAM3 Segment (concepts: building, window, door)
| Lỗi báo một tệp không phải checkpoint `.pt` của SAM 3 | Hãy dùng `sam3.pt`. Các tệp safetensors trên Hugging Face không dùng được với node này. |
| Lỗi báo không đọc được một tệp như checkpoint SAM 3, hoặc tệp đó không phải checkpoint SAM 3 | Tệp trong thư mục `sam3` là bản tải dở hoặc là `.pt` của model khác. Hãy tải lại `sam3.pt` như trong [Trước khi bắt đầu với SAM 3](#trước-khi-bắt-đầu-với-sam-3). |
| Lỗi báo SAM 3 cần GPU NVIDIA có CUDA | ComfyUI đang chạy trên CPU hoặc trên loại GPU khác. SAM3 Segment không có chế độ CPU. |
| Lỗi báo bạn phải viết ít nhất một concept | `concepts` đang trống hoặc chỉ có dòng ghi chú. Hãy viết một từ như `person`. |
| `count` bằng 0, hoặc thiếu một instance | Hạ `threshold`, thử từ khác cho concept, và xem kết quả bằng Segs Preview. |
| Lỗi báo bạn phải viết ít nhất một concept | `concepts` đang trống, chỉ có dòng ghi chú, hoặc chỉ có các từ khóa `all`, `image` hay `none`. Hãy viết một từ như `person`. |
| Lỗi báo một concept có ký tự đại diện (wildcard) | Hãy bỏ `*` hoặc `?`: SAM 3 cần từ thông thường. Ký tự đại diện dùng được trong Segs Select và Segs Compose. |
| Lỗi từ SAM3 Segment có nêu một vị trí, như `unexpected '(' at position 8` | Kiểm tra công thức tại vị trí đó. Dấu ngoặc trong một nhãn được đọc là nhóm. Xem [Viết công thức](#viết-công-thức). |
| `count` bằng 0, hoặc thiếu một instance | Hãy đọc báo cáo: nó cho biết từ đó không tìm thấy gì, một ứng viên suýt đạt (hạ `threshold` xuống điểm của nó), hay từ đó không được hiểu (hãy viết bằng tiếng Anh). Bản xem trước trên node cho thấy những gì đã tìm được. |
| Báo cáo ghi một từ không được hiểu | Đó là một cụm tiếng Việt không có trong danh sách. Hãy viết bằng tiếng Anh. Sau một thứ, `xanh` không rõ nghĩa: hãy viết `xanh dương` (blue) hoặc `xanh lá` (green). |
| Mask là cả bức ảnh, hoặc không như bạn mong đợi | Danh sách là phép hợp: `person, background` là người cộng với mọi thứ nằm ngoài người. Hãy viết riêng `background`, hoặc một công thức như `person - bag`, và nhìn bản xem trước: phần sáng là mask. |
| SAM3 Segment vẫn chạy dù không có gì nối vào nó | Nó là node đầu ra (output node). Hãy vô hiệu hóa (Mute) hoặc xóa nó khi không cần; tắt `show_preview` không làm nó ngừng chạy. |
| Console báo đã giữ mask lượt đầu cho một số instance | Ảnh cận cảnh của chúng không khớp với lượt đầu, nên các instance đó giữ cạnh của lượt đầu. Không có gì sai. |
| 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í đó. Đặt nhãn có dấu cách trong ngoặc kép: `"red car"`. |
| 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`. |
| 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, 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. |
| Nút Unload Models của ComfyUI không giải phóng VRAM của SAM 3 | `keep_model_loaded` đang bật. Hãy tắt nó và chạy node một lần, hoặc khởi động lại ComfyUI. |
---
+26 -24
View File
@@ -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
+9 -9
View File
@@ -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"
}
+36 -6
View File
@@ -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:
+5 -5
View File
@@ -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}),
+5 -4
View File
@@ -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(
+2 -2
View File
@@ -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=""):
+27 -24
View File
@@ -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":
+9 -4
View File
@@ -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)
+16 -17
View File
@@ -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"
}
+7 -7
View File
@@ -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)"
}
+1 -3
View File
@@ -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"
}
+1 -1
View File
@@ -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):
+6 -6
View File
@@ -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:
+9 -9
View File
@@ -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,
+8 -8
View File
@@ -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
+380
View File
@@ -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)",
}
+93 -18
View File
@@ -1,10 +1,25 @@
"""SAM3 Segment (NH): every instance of the concepts you name, as NH_SEGS plus their union mask.
"""SAM3 Segment (NH): find what you name with SAM 3 and get its mask, straight from a short formula.
The model work lives in core/sam3_backend.py; this module only maps the node's inputs and outputs.
The model work lives in core/sam3_backend.py and the formula (labels, + - & ~, background, Vietnamese words) in
core/segs.py; this module maps the node's inputs and outputs and draws its preview.
"""
from ...core import sam3_backend as sam3
from ...core.segs import SEGS_TYPE, count_segments, parse_label_list, segs_union
from ...core.segs import (
SEGS_TYPE,
EmptyExpressionError,
ExpressionError,
count_segments,
describe_request,
expression_labels,
expression_masks,
fold_label,
parse_concepts,
preview_picture,
stamped,
uses_keyword,
)
from ...core.ui_preview import MAX_PREVIEWS, save_temp_previews
try:
from comfy.model_management import throw_exception_if_processing_interrupted
@@ -18,8 +33,28 @@ except Exception: # outside ComfyUI (tests)
ProgressBar = None
def concepts_to_find(tree):
"""(concepts SAM 3 must look for, the ones the node added) for a parsed request, without repeats.
``background`` needs to know where the subject is, so "person" is added when the request does not name it.
"""
names, seen = [], set()
for label in expression_labels(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 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:
names.append("person")
added.append("person")
return names, added
class NH_SAM3Segment:
"""Find every instance of each concept with SAM 3 (Meta), at the full resolution of the image."""
"""Find every instance of the concepts you name with SAM 3 (Meta), at the full resolution of the image."""
@classmethod
def INPUT_TYPES(cls):
@@ -28,12 +63,16 @@ class NH_SAM3Segment:
"image": ("IMAGE",),
"concepts": ("STRING", {
"default": "person", "multiline": True,
"tooltip": "What to find: short English noun phrases, one per line or separated by commas "
"(person, red car, window, sky). Each phrase becomes the label of its instances.",
"tooltip": "What to find, in short English words (person, red car, window, sky); common "
"Vietnamese words work too (tóc, váy, túi, nền). Commas or new lines list several. "
"The mask follows the formula: person - bag (minus), hair + face (plus), ~person "
"(everything else), background (everything outside what was found; people are "
"searched for it), shirt & jacket (overlap), parentheses.",
}),
"threshold": ("FLOAT", {
"default": 0.5, "min": 0.05, "max": 0.95, "step": 0.01,
"tooltip": "Minimum confidence. Lower finds more (and more mistakes), higher keeps only sure ones.",
"tooltip": "Minimum confidence. Lower finds more (and more mistakes), higher keeps only sure ones. "
"The report says how close a concept that was not found came.",
}),
"detail_level": (list(sam3.DETAIL_LEVELS), {
"default": "balanced",
@@ -53,34 +92,70 @@ class NH_SAM3Segment:
"keep_model_loaded": ("BOOLEAN", {
"default": False,
"tooltip": "Keep SAM 3 on the GPU after the run (about 3.6 GB): faster when it runs again, but "
"ComfyUI cannot use that VRAM meanwhile.",
"ComfyUI cannot use that VRAM meanwhile, and its Unload Models button does not "
"release it (turn this off and run the node once, or restart ComfyUI).",
}),
}
},
# optional, so prompts saved before it existed still validate
"optional": {
"show_preview": ("BOOLEAN", {
"default": True,
"tooltip": "Show the mask (bright), the instances found (outlined and labelled) and the report on "
"the node itself. The node runs even with nothing connected.",
}),
},
}
RETURN_TYPES = (SEGS_TYPE, "MASK", "INT")
RETURN_NAMES = ("segs", "mask", "count")
RETURN_TYPES = (SEGS_TYPE, "MASK", "INT", "STRING")
RETURN_NAMES = ("segs", "mask", "count", "report")
OUTPUT_NODE = True # it shows its own preview, so it runs even with nothing connected
FUNCTION = "segment"
CATEGORY = "NH-Nodes/Vision"
DESCRIPTION = (
"Find every instance of the concepts you name (people, garments, objects, sky, walls, windows...) with "
"Meta's SAM 3. Outputs the instances (segs) for Segs Select / Compose / To Masks / Preview, their union mask "
"and their number. Needs the sam3 package, models/sam3/sam3.pt and a CUDA GPU."
"Type what you want and get its mask: 'hair', 'person - bag', 'background', 'tóc, váy'. SAM 3 (Meta) finds "
"every instance at full resolution; the node shows them on itself with a report of what was and was not "
"found. segs feed Segs Select / Compose / To Masks for finer picking. Needs the sam3 package, "
"models/sam3/sam3.pt and a CUDA GPU."
)
SEARCH_ALIASES = ["sam3", "sam 3", "segment anything", "text segment", "concept segment", "object mask",
"detect objects", "instance segmentation"]
"detect objects", "instance segmentation", "background mask", "mask formula"]
def segment(self, image, concepts, threshold, detail_level, max_per_concept, checkpoint, keep_model_loaded):
names = parse_label_list(concepts)
def segment(self, image, concepts, threshold, detail_level, max_per_concept, checkpoint, keep_model_loaded,
show_preview=True):
try:
tree = parse_concepts(concepts)
except EmptyExpressionError:
raise ValueError("[NH-Nodes] SAM3 Segment: write at least one concept, e.g. 'person' or 'window'.") \
from None
except ExpressionError as exc:
raise ValueError(f"[NH-Nodes] SAM3 Segment: {exc}") from None
names, added = concepts_to_find(tree)
if not names:
raise ValueError("[NH-Nodes] SAM3 Segment: write at least one concept, e.g. 'person' or 'window'.")
bar = ProgressBar(image.shape[0] * len(names)) if ProgressBar is not None else None
misses = {}
segs = sam3.segment(
image, names, threshold, detail_level, max_per_concept, checkpoint, keep_model_loaded,
interrupt=throw_exception_if_processing_interrupted,
progress=(lambda done, total: bar.update_absolute(done, total)) if bar is not None else None,
misses=misses,
)
return segs, segs_union(segs), count_segments(segs)
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])
mask = expression_masks(tree, segs)
result = (segs, mask, count, report)
if not show_preview:
return result
try:
pictures = [preview_picture(picture, frame, lines, mask=held) for picture, frame, held in
zip(image[:MAX_PREVIEWS], segs[:MAX_PREVIEWS], mask[:MAX_PREVIEWS])]
images = save_temp_previews(pictures, "NH_sam3")
except Exception as exc: # noqa: BLE001 - a preview that cannot be drawn or saved must not lose the run
print(f"[NH-Nodes] SAM3 Segment: could not show the preview ({exc}); the outputs are fine")
return result
return {"ui": {"images": images}, "result": result}
NODE_CLASS_MAPPINGS = {
+49 -16
View File
@@ -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,
@@ -18,6 +20,7 @@ from ...core.segs import (
select_segments,
to_impact_segs,
)
from ...core.vi_words import translate
CATEGORY = "NH-Nodes/Vision"
@@ -32,8 +35,8 @@ class NH_SegsSelect:
"segs": (SEGS_TYPE,),
"labels": ("STRING", {
"default": "",
"tooltip": "Labels to keep, separated by commas; * and ? are wildcards (\"*shirt\"). "
"Empty keeps every label.",
"tooltip": "Labels to keep, separated by commas; * and ? are wildcards (\"*shirt\"). Common "
"Vietnamese words work too (tóc, túi). Empty keeps every label.",
}),
"min_score": ("FLOAT", {"default": 0.0, "min": 0.0, "max": 1.0, "step": 0.01,
"tooltip": "Drop instances with a lower confidence."}),
@@ -64,7 +67,12 @@ class NH_SegsSelect:
SEARCH_ALIASES = ["segs select", "filter segs", "pick instance", "largest object", "leftmost person"]
def select(self, segs, labels, min_score, min_area, max_area, sort_by, start, count):
patterns = parse_label_list(labels)
patterns = []
for pattern in parse_label_list(labels):
english, status = translate(pattern)
# "nền" / "tất cả" are formula words, not labels: Select matches labels only
patterns.append(english if status == "translated" and english not in ("background", "all", "image")
else pattern)
picked = tuple(
select_segments(frame, patterns, min_score, min_area, max_area, sort_by, start, count) for frame in segs
)
@@ -77,14 +85,26 @@ 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", {
"default": "all", "multiline": True,
"tooltip": "Labels combined with | or + (union), - (minus), & (overlap), ~ (everything except) "
"and parentheses. all = every instance, image = whole frame, none = nothing. Quote "
"labels with spaces (\"red car\") or use _ (red_car); t-shirt needs no quotes. Put "
"spaces around a minus to subtract. Examples: person - hair · ~person · "
"and parentheses; commas or new lines list several. all = every instance, background = "
"everything outside them, image = whole frame, none = nothing. Words in a row are one "
"label (red car); t-shirt needs no quotes; Vietnamese works (tóc, nền). Put spaces "
"around a minus to subtract. Examples: person - hair · background · "
"(wall | ceiling) - window.",
}),
}
@@ -95,29 +115,42 @@ class NH_SegsCompose:
FUNCTION = "compose"
CATEGORY = CATEGORY
DESCRIPTION = (
"Turn instances into one mask with a small formula over their labels: | or + joins, - removes, & keeps the "
"overlap, ~ inverts. ~person is the background around people; (building | roof) - window keeps the facade."
"Turn instances into one mask with a small formula over their labels: | or + (or a comma) joins, - removes, "
"& keeps the overlap, ~ inverts, background is everything outside them. (building | roof) - window keeps the "
"facade. SAM3 Segment takes the same formula in its concepts, so this node is for mixing or re-using segs."
)
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(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),)
+336 -31
View File
@@ -1,15 +1,29 @@
"""Vision segmentation nodes: garment parts and portrait regions.
Both nodes run a SegFormer label map (see segformer_backend.py), union the
class ids for the requested region, refine the mask, and return
(mask, cutout_rgba, mask_image) at the input resolution.
Both nodes build a SegFormer label map of the whole picture (see segformer_backend.py), union the class ids for
the requested region, refine the mask, and return (mask, cutout_rgba, mask_image) at the input resolution. The
parsers see 512 x 512 pictures, so they do not get the whole frame squeezed: Portrait Segment parses a crop around
each face (found with YuNet, see face_detect.py), Garment Segment a crop of the model's shape around the clothes
(see parse_crops.py).
"""
import cv2
import numpy as np
import torch
from ...core.face_detect import detect_faces
from ...core.parse_crops import (
inner_mask,
nearest_owner,
parse_region,
people_labels,
person_box,
square_around,
square_frame,
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
@@ -36,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
@@ -54,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)
@@ -115,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]
@@ -126,33 +178,245 @@ def _image_batch_to_numpy(image):
return [np.clip(frame[:, :, :3], 0.0, 1.0) for frame in array]
def _run_batch(image, model_name, device, build_mask, expand, blur, holes):
"""Shared per-image loop: predict labels, build mask via callback, refine, pack, stack."""
# ---------------------------------------------------------------------------
# Label maps of the whole picture, parsed on crops
# ---------------------------------------------------------------------------
# Square crops around each face, in face-box sides: the wide one keeps long hair and the neck, the tight one (moved
# up a little, for the forehead and hair line) is framed like the faces the parser learnt on, for eyes, brows and lips.
FACE_WIDE = 3.6
FACE_TIGHT = 2.2
FACE_LIFT = 0.06
GARMENT_MARGIN = 0.08 # around the clothes found on the first look, as a fraction of their longer side
FACE_CONTEXT = 1 / 3 # grey added on each side for a second look when no face is found (a face cut by the frame)
BIG_FACE = 0.9 # a face whose tight crop would span this share of the picture's long side is parsed whole-frame
FACE_CORE = 1.3 # the tight crop decides only inside an ellipse of the face box grown this much, and only about
# the face itself (see FACE_DETAIL): hair, neck and background come from the wide crop, which sees them whole instead
# of cut by the tight crop's edges.
# CelebAMask-HQ classes of the face parser: what a face is made of, and the features that make it a face
FACE_PARTS = frozenset({1, 2, 3, 4, 5, 6, 7, 10, 11, 12}) # skin, nose, glasses, eyes, brows, mouth, lips
FACE_FEATURES = frozenset({2, 4, 5, 6, 7, 10, 11, 12}) # nose, eyes, brows, mouth, lips
FACE_DETAIL = FACE_PARTS | {8, 9} # with the ears: what the tight crop may decide
_detection_failed = False
def find_faces(image, second_look=True):
"""Face boxes of the uint8 RGB ``image``. A face cut by the frame is often found only once the picture gets a
grey margin around it, so that is tried when the first look finds none; boxes mostly outside the frame are left
out. If the detector cannot be loaded (offline first run, an old OpenCV, a damaged file) the node says so once
and parses whole frames, as before the detector existed."""
global _detection_failed
if _detection_failed:
return []
try:
faces = detect_faces(image)
if faces or not second_look:
return faces
height, width = image.shape[:2]
top, left = int(height * FACE_CONTEXT), int(width * FACE_CONTEXT)
padded = cv2.copyMakeBorder(np.ascontiguousarray(image), top, top, left, left, cv2.BORDER_CONSTANT,
value=(114, 114, 114))
found = []
for x1, y1, x2, y2, score in detect_faces(padded):
box = (max(0.0, x1 - left), max(0.0, y1 - top), min(float(width), x2 - left), min(float(height), y2 - top))
inside = max(0.0, box[2] - box[0]) * max(0.0, box[3] - box[1])
if inside >= 0.5 * (x2 - x1) * (y2 - y1):
found.append((*box, score))
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] The face detector is not available ({exc}); Portrait, Garment and Body Segment parse "
"whole frames instead. Restart ComfyUI to try again.")
return []
def is_face(labels, seen, face):
"""True when ``labels`` (the parse of the part ``seen`` of a tight crop) show a face inside the detected box:
mostly face parts, with some nose, eye, brow or lip. A hand or a pattern the detector took for a face is turned
down, so its crops change nothing."""
sx1, sy1 = seen[:2]
fx1, fy1, fx2, fy2 = (int(round(value)) for value in face[:4])
inside = labels[max(0, fy1 - sy1):max(0, fy2 - sy1), max(0, fx1 - sx1):max(0, fx2 - sx1)]
if inside.size == 0:
return False
return (np.isin(inside, list(FACE_PARTS)).mean() >= 0.3
and np.isin(inside, list(FACE_FEATURES)).mean() >= 0.005)
def portrait_labels(bundle, image):
"""Face-parsing label map of the uint8 RGB ``image``.
The whole frame is parsed first, as a padded square (the parser learnt on square face pictures), and that answer
is kept wherever the crops below do not reach. Each face found, unless it already fills the frame, is parsed again
on a tight square crop (eyes, brows, lips) and, once that parse confirms it is a face, on a wide one (hair, neck).
The wide crop is pasted inside its region away from its cut edges, the tight one only around the face itself, and
each only where its face is the nearest one, so neighbours in a group photo do not overwrite each other and no
straight crop edge shows in long hair.
"""
def predict(picture):
throw_exception_if_processing_interrupted()
return predict_labels(bundle, picture)
height, width = image.shape[:2]
canvas = parse_region(predict, image, square_frame((height, width)))[0]
long_side = max(height, width)
candidates = [face for face in find_faces(image)
if FACE_TIGHT * max(face[2] - face[0], face[3] - face[1]) < BIG_FACE * long_side]
tight = []
for face in candidates:
region = square_around(face, FACE_TIGHT, FACE_LIFT)
labels, seen = parse_region(predict, image, region)
if labels.size and is_face(labels, seen, face):
tight.append((face, region, labels, seen))
if not tight:
return canvas
faces = [face for face, _, _, _ in tight]
wide_regions = [square_around(face, FACE_WIDE) for face in faces]
# with the faces known, face parts the whole-frame parse saw far from all of them are mistakes (a reflection,
# a pattern): only hair, neck and clothes are kept there
near_faces = np.zeros((height, width), dtype=bool)
for x1, y1, x2, y2 in (visible(region, (height, width)) for region in wide_regions):
near_faces[y1:y2, x1:x2] = True
canvas[~near_faces & np.isin(canvas, list(FACE_DETAIL))] = 0
tight_regions = [region for _, region, _, _ in tight]
for index, region in enumerate(wide_regions):
labels, seen = parse_region(predict, image, region)
_paste(canvas, labels, seen,
nearest_owner(seen, faces, wide_regions, index) & inner_mask(region, (height, width)))
detail = list(FACE_DETAIL)
for index, (face, region, labels, seen) in enumerate(tight):
x1, y1, x2, y2 = seen
about_face = np.isin(labels, detail) | np.isin(canvas[y1:y2, x1:x2], detail)
_paste(canvas, labels, seen, nearest_owner(seen, faces, tight_regions, index) & _core(face, seen) & about_face)
return canvas
def _core(face, seen):
"""Bool mask over ``seen`` (a region inside the picture): the ellipse of the face box grown FACE_CORE times."""
x1, y1, x2, y2 = face[:4]
cx, cy = (x1 + x2) / 2.0, (y1 + y2) / 2.0
half_w, half_h = max(1.0, FACE_CORE * (x2 - x1) / 2.0), max(1.0, FACE_CORE * (y2 - y1) / 2.0)
sx1, sy1, sx2, sy2 = seen
ys = (np.arange(sy1, sy2, dtype=np.float32)[:, None] + 0.5 - cy) / half_h
xs = (np.arange(sx1, sx2, dtype=np.float32)[None, :] + 0.5 - cx) / half_w
return xs * xs + ys * ys <= 1.0
def _paste(canvas, labels, seen, keep):
if labels.size:
x1, y1, x2, y2 = seen
canvas[y1:y2, x1:x2][keep] = labels[keep]
def garment_labels(bundle, image):
"""Fashion-parsing label map of the uint8 RGB ``image``: a first look at the whole frame finds the clothes, then
crops of the model's shape around the people (found from the clothes and from their faces; a row of crops for a
group; not squeezed, padded where they leave the picture) are parsed again and merged by confidence (see
parse_crops.people_labels)."""
size = getattr(bundle.get("processor"), "size", None) or {"height": 1, "width": 1}
def predict(picture):
throw_exception_if_processing_interrupted()
return predict_labels(bundle, picture, confidence=True)
people = [person_box(face, image.shape[:2]) for face in find_faces(image, second_look=False)]
return people_labels(predict, image, size["width"] / size["height"], GARMENT_MARGIN, people)
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, 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:
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)
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 _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 = predict_labels(bundle, (frame * 255.0).round().astype(np.uint8))
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"}),
}
@@ -167,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),
mask_expand, mask_blur, fill_holes,
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,
)
@@ -207,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
@@ -228,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,
mask_expand, mask_blur, fill_holes, portrait_labels, PORTRAIT_SEGS,
)
+2 -3
View File
@@ -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
+10 -7
View File
@@ -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))
+2 -2
View File
@@ -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 with instance selection and mask formulas, garment/portrait segmentation, and VTON preprocessing."
version = "1.7.1"
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",
+15
View File
@@ -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())])
+504
View File
@@ -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"]
+430
View File
@@ -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)
+2
View File
@@ -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")
+1 -1
View File
@@ -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()
+292
View File
@@ -0,0 +1,292 @@
"""Crops for the parsers (faces, clothes) and YuNet face boxes, without models."""
import importlib
import numpy as np
import pytest
crops = importlib.import_module("NH-Nodes.core.parse_crops")
faces = importlib.import_module("NH-Nodes.core.face_detect")
def test_square_around_scales_the_longer_side_and_lifts():
assert crops.square_around((40, 50, 60, 80), 2.0) == (20, 35, 80, 95) # 30 px tall -> 60 px square
assert crops.square_around((40, 50, 60, 80), 2.0, lift=0.1) == (20, 29, 80, 89) # up by 0.1 * 60
assert crops.square_around((0, 0, 0, 0), 3.0) == (0, 0, 1, 1) # never empty
@pytest.mark.parametrize("box, aspect", [((100, 100, 140, 300), 1.0), ((10, 10, 50, 30), 2 / 3),
((500, 20, 590, 390), 0.75), ((0, 0, 600, 400), 1.0)])
def test_fit_box_has_the_model_shape_and_holds_the_box(box, aspect):
region = crops.fit_box(box, aspect, 0.08, (400, 600))
x1, y1, x2, y2 = region
assert x1 <= box[0] and y1 <= box[1] and x2 >= box[2] and y2 >= box[3]
assert abs((x2 - x1) / (y2 - y1) - aspect) < 0.02
if (x2 - x1) <= 600:
assert 0 <= x1 and x2 <= 600 # slid inside where it fits
if (y2 - y1) <= 400:
assert 0 <= y1 and y2 <= 400
def test_cut_padded_pads_the_outside_with_grey():
image = np.full((10, 20, 3), 200, np.uint8)
crop = crops.cut_padded(image, (-5, 5, 10, 15))
assert crop.shape == (10, 15, 3)
assert (crop[:5, 5:] == 200).all() and (crop[:, :5] == crops.PAD_VALUE).all() and (crop[5:] == crops.PAD_VALUE).all()
assert crops.visible((-5, 5, 10, 15), (10, 20)) == (0, 5, 10, 10)
def test_nearest_owner_splits_overlapping_crops_between_their_faces():
boxes = [(0, 0, 10, 10), (20, 0, 30, 10)] # face centres at x = 5 and x = 25
regions = [(0, 0, 30, 10), (0, 0, 30, 10)]
left = crops.nearest_owner((0, 0, 30, 10), boxes, regions, 0)
right = crops.nearest_owner((0, 0, 30, 10), boxes, regions, 1)
assert left[:, :15].all() and not left[:, 15:].any() and (left ^ right).all()
# a face whose crop does not reach a pixel cannot take it
alone = crops.nearest_owner((0, 0, 30, 10), boxes, [(0, 0, 30, 10), (20, 0, 30, 10)], 0)
assert alone[:, :20].all() and not alone[:, 20:].any()
def test_cover_box_uses_a_row_of_crops_for_a_wide_group():
group = (100, 300, 7300, 4800) # five people side by side on a 7360 x 4912 photo
regions = crops.cover_box(group, 2 / 3, 0.0, (4912, 7360))
assert len(regions) >= 3
for x1, y1, x2, y2 in regions:
assert abs((x2 - x1) / (y2 - y1) - 2 / 3) < 0.02 and y1 <= 300 and y2 >= 4800
assert regions[0][0] <= 100 and regions[-1][2] >= 7300 # together they cover the group
assert all(a[2] > b[0] for a, b in zip(regions, regions[1:])) # and overlap
assert crops.cover_box((100, 100, 300, 500), 2 / 3, 0.08, (4912, 7360)) == [
crops.fit_box((100, 100, 300, 500), 2 / 3, 0.08, (4912, 7360))] # one person: one crop
assert len(crops.cover_box((100, 0, 200, 4900), 1.0, 0.0, (4912, 7360))) == 1 # tall: never split
def test_square_frame_pads_the_short_side():
assert crops.square_frame((100, 200)) == (0, -50, 200, 150)
assert crops.square_frame((300, 100)) == (-100, 0, 200, 300)
def _people_picture():
"""300 x 900 picture: a wide group (red 40) in rows 100-280, columns 50-850; a plain backdrop (red 200) elsewhere."""
image = np.full((300, 900, 3), 200, np.uint8)
image[100:280, 50:850, 0] = 40
return image
def test_people_labels_refines_the_first_look_and_keeps_it_elsewhere():
image = _people_picture()
shown = []
def predict(picture):
shown.append(picture.shape[:2])
person = picture[..., 0] == 40
if picture.shape[:2] == (300, 900): # the first look: right kind of thing, wrong label, unsure
return np.where(person, 3, 0).astype(np.int64), np.full(picture.shape[:2], 0.6, np.float16)
return np.where(person, 5, 0).astype(np.int64), np.full(picture.shape[:2], 0.9, np.float16)
labels = crops.people_labels(predict, image, 2 / 3)
assert shown[0] == (300, 900) and len(shown) >= 4 # a first look, then a row of crops over the group
group = labels[100:280, 50:850]
assert (group == 5).mean() > 0.97 and set(np.unique(group)) <= {3, 5} # refined; a few cut edges keep the look
assert not labels[:100].any() and not labels[:, :40].any() # the backdrop stays empty
def test_people_labels_invents_nothing_far_from_people():
image = np.full((300, 900, 3), 200, np.uint8)
image[100:280, 50:150, 0] = 40 # one person at the left
def predict(picture):
person = picture[..., 0] == 40
labels = np.where(person, 5, 0).astype(np.int64)
if picture.shape[:2] != (300, 900):
labels[picture[..., 0] == 200] = 7 # a crop "sees" clothes on the plain backdrop
return labels, np.full(picture.shape[:2], 0.9, np.float16)
labels = crops.people_labels(predict, image, 2 / 3)
assert (labels[100:280, 50:150] == 5).all()
columns = np.argwhere(labels == 7)[:, 1]
reach = crops.NEAR_PEOPLE * 900 + 2
assert ((columns < 150 + reach) & (columns >= 50 - reach)).all() # only right next to the person, if at all
def test_people_labels_adds_people_found_by_their_faces():
image = np.full((300, 900, 3), 200, np.uint8)
image[100:280, 50:150, 0] = 40
image[100:280, 700:800, 0] = 40 # the first look misses this one
def predict(picture):
person = picture[..., 0] == 40
if picture.shape[:2] == (300, 900):
person[:, 400:] = False
return np.where(person, 5, 0).astype(np.int64), np.full(picture.shape[:2], 0.9, np.float16)
assert not crops.people_labels(predict, image, 2 / 3)[:, 700:800].any()
found = crops.people_labels(predict, image, 2 / 3, people=[(680, 90, 820, 300)])
assert (found[100:280, 700:800] == 5).mean() > 0.95
def test_a_crop_grows_when_its_people_run_past_a_cut_edge():
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[:, 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[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():
labels = np.zeros((200, 1000), np.int64)
labels[50:150, 100:200] = 1
labels[50:150, 210:300] = 1 # 10 px away: same group
labels[50:150, 800:900] = 1 # far away: its own group
labels[5, 5] = 1 # a speck
assert crops.foreground_clusters(labels) == [(100, 50, 300, 150), (800, 50, 900, 150)]
assert crops.foreground_clusters(labels, extra=[(500, 40, 560, 160)]) == [
(100, 50, 300, 150), (500, 40, 560, 160), (800, 50, 900, 150)]
assert crops.foreground_clusters(np.zeros((5, 5), np.int64)) == []
def test_inner_mask_leaves_out_cut_edges_but_not_picture_edges():
keep = crops.inner_mask((-50, 20, 150, 120), (200, 300), band=0.1)
assert keep.shape == (100, 150)
assert keep[10:90, 0].all() # the region reaches past the picture's left edge: nothing left out there
assert not keep[:10].any() and not keep[90:].any() # 10% of 100 px at the cut top and bottom
assert keep[10:90, :130].all() and not keep[:, 130:].any() # and 10% of 200 px at the cut right edge
def test_person_box_reaches_down_from_the_face():
assert crops.person_box((100, 50, 140, 100, 0.9), (1000, 1000)) == (20, 20, 220, 450)
assert crops.person_box((0, 0, 40, 50), (300, 300)) == (0, 0, 120, 300)
# --- YuNet ---------------------------------------------------------------------
class _FakeYuNet:
def __init__(self, rows):
self.rows, self.size = rows, None
def setInputSize(self, size): # noqa: N802 (OpenCV's name)
self.size = size
def detect(self, image):
assert image.shape[:2] == (self.size[1], self.size[0])
return 1, (np.array(self.rows, np.float32) if self.rows else None)
def test_merge_faces_keeps_one_box_per_face():
whole = (100, 100, 200, 220, 0.95)
half = (100, 100, 150, 220, 0.7) # half of the same face, found at another scale
other = (300, 100, 380, 200, 0.8)
assert faces.merge_faces([half, whole, other]) == [whole, other]
assert faces.merge_faces([(0, 0, 10, 10, 0.9), (2, 2, 12, 12, 0.8)]) == [(0, 0, 10, 10, 0.9)]
def test_detect_faces_maps_boxes_back_sorts_and_filters(monkeypatch):
# rows: x, y, w, h, 10 landmark values, score (on the copy shrunk to 2048 px)
rows = [[10, 10, 20, 20] + [0] * 10 + [0.9], [100, 50, 60, 80] + [0] * 10 + [0.95],
[0, 0, 5, 5] + [0] * 10 + [0.3], [2040, 0, 30, 30] + [0] * 10 + [0.8]]
fake = _FakeYuNet(rows)
monkeypatch.setattr(faces, "_get_detector", lambda size: (fake.setInputSize(size), fake)[1])
found = faces.detect_faces(np.zeros((2048, 4096, 3), np.uint8), sides=(2048,))
assert fake.size == (2048, 1024) # looked at a copy half the size
assert [tuple(round(v) for v in f[:4]) for f in found] == [(200, 100, 320, 260), (20, 20, 60, 60),
(4080, 0, 4096, 60)] # largest first, clipped
assert all(f[4] >= faces.MIN_SCORE for f in found)
fake.rows = []
assert faces.detect_faces(np.zeros((100, 100, 3), np.uint8)) == []
seen = []
monkeypatch.setattr(faces, "_detect_at", lambda img, side, score: seen.append(side) or [])
faces.detect_faces(np.zeros((100, 100, 3), np.uint8))
assert seen == list(faces.DETECT_SIDES) # a sharp copy for small faces, a small one for big or grainy faces
def test_model_path_finds_existing_weights_before_downloading(monkeypatch, tmp_path):
monkeypatch.setattr(faces, "register_model_folder", lambda *a: [str(tmp_path / "a"), str(tmp_path / "b")])
monkeypatch.setattr(faces, "default_folder", lambda: str(tmp_path / "a"))
downloads = []
def fake_download(repo, revision, target, patterns):
downloads.append((repo, revision, tuple(patterns)))
(tmp_path / "a" / "yunet").mkdir(parents=True)
(tmp_path / "a" / "yunet" / faces.YUNET["file"]).write_bytes(b"x")
monkeypatch.setattr(faces, "download_pinned", fake_download)
assert faces.model_path(download=False) is None
path = faces.model_path()
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
+1 -1
View File
@@ -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")
+139 -16
View File
@@ -7,6 +7,7 @@ skipped unless all three are present.
import contextlib
import importlib
import os
import re
import sys
import types
@@ -130,6 +131,13 @@ class FakeProcessor:
# best instance, an exact duplicate with a lower score, and a second distinct one
maps = [_prob_map(SQUARE), _prob_map(SQUARE), _prob_map((50, 40, 60, 60))]
scores = [0.9, 0.6, 0.7]
elif prompt == "floor":
# runs off the bottom of the picture; like SAM 3, the outermost row fades to "not sure"
floor = _prob_map((0, 48, 64, 64))
floor[63, :] = 0.45
maps, scores = [floor], [0.8]
elif prompt == "faint":
maps, scores = [_prob_map((4, 4, 12, 12))], [0.3]
else:
maps, scores = [], []
masks = torch.stack(maps)[:, None] if maps else torch.zeros((0, 1, 64, 64))
@@ -137,12 +145,14 @@ class FakeProcessor:
class FakeModel(torch.nn.Module):
"""predict_inst answers with a mask filling the box prompt (or the opposite corner when ``stray``)."""
"""predict_inst answers with a mask filling the box prompt (or the opposite corner when ``stray``); with
``fade`` the outer 2 rows and columns of the close-up are "not sure", as SAM 3's are."""
def __init__(self, stray=False):
def __init__(self, stray=False, fade=False):
super().__init__()
self.weight = torch.nn.Parameter(torch.zeros(1))
self.stray = stray
self.fade = fade
self.prompts = []
def predict_inst(self, state, box=None, mask_input=None, multimask_output=True, return_logits=False):
@@ -154,6 +164,8 @@ class FakeModel(torch.nn.Module):
else:
x1, y1, x2, y2 = np.round(box).astype(int)
logits[0, y1:y2, x1:x2] = 10.0
if self.fade:
logits[0, :2, :] = logits[0, -2:, :] = logits[0, :, :2] = logits[0, :, -2:] = -0.5
return logits, np.array([0.9]), np.zeros((1, 288, 288), np.float32)
@@ -171,6 +183,23 @@ def _run(detail, model=None, concepts=("square", "nothing"), **kwargs):
return frames, processors, model
def test_extend_to_border_lifts_the_faded_band_and_never_lowers_it():
logits = torch.full((7, 7), -0.3) # the fade: unsure everywhere near the border
logits[:5, 2:5] = 4.0 # an object reaching down to row 4 (inside the band of 2)
logits[5, 0] = -6.0 # a sure "no" inside the band
logits[6, 5] = 2.0 # higher than the row inside: kept
out = backend.extend_to_border(logits, 2, 1, edges=("bottom",))
assert (out[5:, 2:5] == 4.0).all() # the object reaches the edge
assert float(out[5, 0]) == -6.0 and float(out[6, 5]) == 2.0 # sure values and higher ones stay
assert float(out[6, 1]) == pytest.approx(-0.3) # nothing inside to lift it to
assert torch.equal(logits[:5], out[:5]) # outside the band nothing changes
both = backend.extend_to_border(logits.numpy(), 2, 2)
assert isinstance(both, np.ndarray) and (both[5:, 2:5] == 4.0).all()
assert backend.extend_to_border(torch.zeros(2, 2), 9, 9).shape == (2, 2) # a band wider than the map is cut
assert backend.border_band(1008) == 3 and backend.border_band(64) == 1 and backend.border_band(7360) == 22
assert backend.border_band(2400, cells=2) == 5 # a close-up fades over about 2 rows of its view
def test_find_instances_drops_duplicates_and_honours_the_cap():
processor = FakeProcessor()
state = processor.set_image(torch.zeros(3, 64, 64))
@@ -182,6 +211,28 @@ def test_find_instances_drops_duplicates_and_honours_the_cap():
assert processor.calls.count(("reset",)) == 3 # every prompt starts from a clean state
def test_find_instances_keeps_the_best_score_under_the_threshold_as_a_near_miss():
processor = FakeProcessor()
state = processor.set_image(torch.zeros(3, 64, 64))
misses = {}
found = backend.find_instances(processor, state, "square", threshold=0.65, misses=misses)
assert [round(score, 2) for score, _, _ in found] == [0.9, 0.7] and misses == {"square": pytest.approx(0.6)}
# SAM 3 keeps scores above the threshold: one exactly on it is a miss, as before
on_it = backend.find_instances(processor, state, "square", threshold=0.7, misses=misses)
assert [round(score, 2) for score, _, _ in on_it] == [0.9]
assert backend.find_instances(processor, state, "faint", threshold=0.5, misses=misses) == []
assert misses["faint"] == pytest.approx(0.3)
def test_instances_running_off_the_picture_reach_its_edge():
for detail in ("fast", "balanced"):
frames, _, _ = _run(detail, model=FakeModel(fade=True), concepts=("floor",))
floor = frames[0].segments[0]
x1, y1, x2, y2 = floor.box
assert (x1, x2, y2) == (0, 600, 300), detail # the full width, down to the last row
assert bool(floor.mask[-1].all()) and bool(floor.mask[:, 0].all()), detail
def test_fast_pass_maps_instances_to_full_resolution():
frames, processors, model = _run("fast")
assert len(frames) == 2 and (frames[0].height, frames[0].width) == (300, 600)
@@ -190,7 +241,8 @@ def test_fast_pass_maps_instances_to_full_resolution():
# model box (16..48, 16..32) of a 64 px square view -> (150..450, 75..150) in the 600 x 300 image
assert all(abs(a - b) <= 2 for a, b in zip(first.box, (150, 75, 450, 150)))
assert len(frames[0].segments) == 2 and model.prompts == []
assert processors[0].threshold == 0.4 and processors[0].calls[0] == ("image", (3, 64, 64))
# candidates are asked for down to NEAR_MISS so that the node can report how close the misses came
assert processors[0].threshold == backend.NEAR_MISS and processors[0].calls[0] == ("image", (3, 64, 64))
def test_balanced_pass_replaces_masks_with_the_close_up_result():
@@ -339,23 +391,94 @@ def test_checkpoint_list_always_has_an_entry(monkeypatch, tmp_path):
# --- node ----------------------------------------------------------------------
def test_segment_node_outputs(monkeypatch):
frame = segs.SegsFrame(8, 10, (segs.make_segment("cat", 0.8, torch.ones((2, 3), dtype=torch.bool), (1, 1)),))
calls = []
def _node_frame():
"""10 x 8 picture: a person filling columns 0-4, a bag inside it (columns 3-4, rows 2-3), a cat at columns 7-8."""
def seg(label, score, x1, y1, x2, y2):
return segs.make_segment(label, score, torch.ones((y2 - y1, x2 - x1), dtype=torch.bool), (x1, y1))
def fake_segment(images, concepts, *args, **kwargs):
calls.append(concepts)
return (frame,)
return segs.SegsFrame(8, 10, (seg("person", 0.9, 0, 0, 5, 8), seg("bag", 0.8, 3, 2, 5, 4),
seg("cat", 0.7, 7, 1, 9, 3)))
def _run_node(monkeypatch, concepts, misses=None, show_preview=False, frame=None):
calls, saved = [], []
def fake_segment(images, names, *args, **kwargs):
calls.append(names)
kwargs["misses"].update(misses or {})
frames = frame or _node_frame()
return tuple(frames.with_segments([s for s in frames.segments if s.label in names]) for _ in images)
monkeypatch.setattr(sam3_nodes.sam3, "segment", fake_segment)
monkeypatch.setattr(sam3_nodes, "save_temp_previews",
lambda pictures, prefix: saved.extend(pictures) or [{"filename": "x.jpg", "type": "temp"}])
out = sam3_nodes.NH_SAM3Segment().segment(torch.full((1, 8, 10, 3), 0.8), concepts, 0.5, "fast", 0, "sam3.pt",
False, show_preview)
return out, calls, saved
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
(_, mask, _, report), calls, _ = _run_node(monkeypatch, "background")
# background needs the subject: person is searched for on its own, and the report says so
assert calls == [["person"]] and int(mask.sum()) == 80 - 40
assert "background = everything outside person" in report and "person (searched for background)" in report
(_, mask, _, report), calls, _ = _run_node(monkeypatch, "nền - mèo, túi")
assert calls == [["cat", "bag", "person"]] and int(mask.sum()) == 80 - 40 - 4 + 4
assert "mèo -> cat: 1 found (best 0.70)" in report and "túi -> bag: 1 found (best 0.80)" in report
def test_segment_node_reports_what_it_could_not_find(monkeypatch):
(_, mask, count, report), _, _ = _run_node(monkeypatch, "dog, giraffe, áo xanh", misses={"dog": 0.31})
assert count == 0 and int(mask.sum()) == 0
assert "dog: none at threshold 0.50; the best candidate scored 0.31, lower the threshold" in report
assert "giraffe: none found; try another English word" in report
assert "áo xanh: not understood; write it in English" in report
@pytest.mark.parametrize("concepts, message", [
(" ,\n# none", "at least one concept"), ("all", "at least one concept"), ("*shirt", "wildcard"),
("person - (bag", "not closed"),
])
def test_segment_node_errors(monkeypatch, concepts, message):
with pytest.raises(ValueError, match=re.escape(message)):
_run_node(monkeypatch, concepts)
def test_segment_node_shows_a_small_preview_with_the_report(monkeypatch):
out, _, saved = _run_node(monkeypatch, "cat, dog", show_preview=True)
assert set(out) == {"ui", "result"} and out["ui"]["images"] == [{"filename": "x.jpg", "type": "temp"}]
assert len(out["result"]) == 4 and len(saved) == 1
picture = saved[0]
assert picture.dtype == np.uint8 and picture.shape[1] == 10 and picture.shape[0] > 8 # a caption strip below
assert sam3_nodes.NH_SAM3Segment.OUTPUT_NODE is True
spec = sam3_nodes.NH_SAM3Segment.INPUT_TYPES()
# optional, so an API prompt saved before 1.8 (without it) still validates; the editor still shows it
assert "show_preview" not in spec["required"] and spec["optional"]["show_preview"][1]["default"] is True
assert spec["required"]["checkpoint"][0]
def test_segment_node_runs_without_show_preview_and_survives_a_failed_preview(monkeypatch, capsys):
frame = _node_frame()
monkeypatch.setattr(sam3_nodes.sam3, "segment", lambda images, names, *a, **k: (frame,))
node = sam3_nodes.NH_SAM3Segment()
result, mask, count = node.segment(torch.rand(1, 8, 10, 3), "cat, dog\ncat", 0.5, "fast", 0, "sam3.pt", False)
assert calls == [["cat", "dog"]] and result == (frame,) and count == 1
assert mask.shape == (1, 8, 10) and int(mask.sum()) == 6
assert sam3_nodes.NH_SAM3Segment.RETURN_NAMES == ("segs", "mask", "count")
with pytest.raises(ValueError, match="at least one concept"):
node.segment(torch.rand(1, 8, 10, 3), " ,\n# none", 0.5, "fast", 0, "sam3.pt", False)
assert sam3_nodes.NH_SAM3Segment.INPUT_TYPES()["required"]["checkpoint"][0]
out = node.segment(torch.full((1, 8, 10, 3), 0.8), "cat", 0.5, "fast", 0, "sam3.pt", False) # as a 1.7 prompt
assert set(out) == {"ui", "result"} or len(out) == 4
def broken(pictures, prefix):
raise OSError("disk full")
monkeypatch.setattr(sam3_nodes, "save_temp_previews", broken)
result = node.segment(torch.full((1, 8, 10, 3), 0.8), "cat", 0.5, "fast", 0, "sam3.pt", False, True)
assert isinstance(result, tuple) and len(result) == 4 and int(result[1].sum()) == 4
assert "could not show the preview" in capsys.readouterr().out
# --- real model ----------------------------------------------------------------
+249 -3
View File
@@ -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 ------------------------------------------------------------------
@@ -136,15 +137,48 @@ def test_compose_precedence_and_associativity():
@pytest.mark.parametrize("expression, message", [
("", "empty"), (" ", "empty"), ("'red car", "never closed"), ("(person | hair", "not closed"),
("person |", "ends where"), ("person hair", "unexpected 'hair' at position 8"), (")", "unexpected ')'"),
("", "empty"), (" ", "empty"), (" , \n # only a comment", "empty"), ("'red car", "never closed"),
("(person | hair", "not closed"), ("person |", "ends where"), (")", "unexpected ')'"),
("person !", "unexpected '!' at position 8"), ("(person or)", "unexpected ')' at position 11"),
("person | , hair", "unexpected ','"), ("()", "holds nothing"), ("hair (person)", "unexpected '(' at position 6"),
])
def test_compose_errors_explain_where(expression, message):
with pytest.raises(segs.ExpressionError, match=re.escape(message)):
segs.parse_expression(expression)
def test_lists_comments_and_words_in_a_row():
# commas and new lines join like |, after every other operator; words in a row are one label
assert _px("person, red car") == _px("person\nred car") == _px("person | 'red car'") == 1400
assert _px("red car") == _px("Red Car") == 200 and _px("person hair") == 0 # one (unknown) label
assert _px("person - hair, red car") == 900 + 200
assert _px("(hair, red car) - person") == 200 and _px("person,,\n\nhair,") == 1200
assert _px("hair # the hair only\n# person\n") == 300
def test_background_is_everything_outside_the_instances():
assert _px("background") == 2400 - 1400 == _px("~all") and _px("~background") == _px("all")
assert _px("background - red car") == 1000 and _px("background | hair") == 1300
def test_vietnamese_labels_and_keywords():
frame = _frame(_seg("hair", 0.8, (0, 0, 30, 10)), _seg("bag", 0.7, (40, 30, 60, 40)))
assert _px("tóc", frame) == 300 and _px("tóc, túi", frame) == 500 and _px("nền", frame) == 2400 - 500
assert _px("tất cả - túi", frame) == 300 and _px("'nền'", frame) == 0 # quoted: a label, never a keyword
tree = segs.parse_expression("tóc - túi, nền, áo xanh, sky")
assert segs.expression_labels(tree) == ["hair", "bag", "áo xanh", "sky"]
assert segs.expression_terms(tree) == [("tóc", "hair", "translated"), ("túi", "bag", "translated"),
("nền", "background", "keyword"), ("áo xanh", "áo xanh", "unknown"),
("sky", "sky", "english")]
assert segs.uses_keyword(tree, "background") and not segs.uses_keyword(tree, "all")
def test_sam3_concepts_read_and_or_not_as_words():
tree = segs.parse_concepts("black and white dog, person - bag")
assert segs.expression_labels(tree) == ["black and white dog", "person", "bag"]
assert segs.expression_labels(segs.parse_expression("person and hair")) == ["person", "hair"]
def test_compose_node_reports_unmatched_labels(capsys):
(mask,) = segs_nodes.NH_SegsCompose().compose((_compose_frame(),), "person - ghost")
assert mask.shape == (1, 40, 60) and int(mask.sum()) == 1200
@@ -159,6 +193,27 @@ def test_compose_node_catches_a_minus_without_spaces():
assert int(segs_nodes.NH_SegsCompose().compose((_compose_frame(),), "person-ghost")[0].sum()) == 0
def test_preview_picture_shrinks_darkens_outside_the_mask_and_adds_notes():
frame = segs.SegsFrame(400, 600, (_seg("car", 0.9, (300, 200, 500, 380), 400, 600),))
image = torch.full((400, 600, 3), 0.8)
mask = torch.zeros((400, 600))
mask[200:380, 300:500] = 1.0
picture = segs.preview_picture(image, frame, ["car: 1 found"], max_side=300, mask=mask)
assert picture.dtype == np.uint8 and picture.shape[1] == 300 and picture.shape[0] > 200 # caption strip below
assert abs(int(picture[20, 20, 0]) - round(204 * 0.35)) <= 1 # outside the mask: darkened
assert int(picture[150, 200].min()) > 140 # inside: bright, lightly tinted
plain = segs.preview_picture(image, frame)
assert plain.shape == (400, 600, 3) and int(plain[20, 20, 0]) == 204 # no mask, no notes: as Segs Preview
def test_scale_frame_keeps_tiny_instances_visible():
frame = segs.SegsFrame(400, 600, (_seg("dot", 0.9, (10, 10, 12, 12), 400, 600),
_seg("car", 0.8, (300, 200, 500, 380), 400, 600)))
small = segs.scale_frame(frame, 0.1)
assert (small.height, small.width) == (40, 60) and [s.label for s in small.segments] == ["dot", "car"]
assert small.segments[0].area == 1 and small.segments[1].box == (30, 20, 50, 38)
# --- Impact Pack SEGS --------------------------------------------------------
@pytest.mark.parametrize("box, factor, expected", [
((20, 10, 30, 20), 3.0, [10, 0, 40, 30]), # 30 x 30 centred on the box
@@ -276,3 +331,194 @@ def test_segs_preview_node_and_every_node_declares_matching_outputs():
assert len(cls.RETURN_TYPES) == len(cls.RETURN_NAMES) and cls.CATEGORY == "NH-Nodes/Vision"
assert callable(getattr(cls(), cls.FUNCTION)) and required
assert all(isinstance(spec, tuple) and spec for spec in required.values())
def test_formulas_laid_out_over_several_lines_still_read():
# a line break next to an operator joins (as on 1.7), a line break between two labels lists them
assert _px("person\n- hair") == _px("person -\nhair") == _px("person\r\n- hair") == 900
assert _px("(person | 'red car')\n- hair") == 1100 and _px("~\nperson") == 1200
assert _px("hair\n| 'red car'") == 500 and _px("person &\nhair") == 300
assert _px("(\nperson\n)") == 1200 and _px("hair\n\n- hair") == 0
assert _px("person\nhair") == 1200 and _px("hair\n~person") == 300 + 1200 # a list; ~person is an item
def test_comments_start_only_where_a_word_could():
frame = _frame(_seg("car#2", 0.9, (0, 0, 10, 10)), _seg("car", 0.8, (20, 20, 30, 30)))
assert _px("car#2", frame) == 100 and _px("car # the second\n", frame) == 100
with pytest.raises(segs.EmptyExpressionError):
segs.parse_expression("# nothing but a comment")
with pytest.raises(segs.ExpressionError) as caught:
segs.parse_expression("(person) empty")
assert not isinstance(caught.value, segs.EmptyExpressionError)
def test_report_trusts_what_was_found_over_the_dictionary():
frame = _frame(_seg("café", 0.9, (0, 0, 10, 10)))
lines = segs.describe_request(segs.parse_concepts("café, bánh xèo"), (frame,), 0.5)
assert lines[0] == "café: 1 found (best 0.90)" # not Vietnamese after all: SAM 3 found it
assert lines[1].startswith("bánh xèo: not understood")
def test_compose_node_catches_words_left_without_an_operator():
with pytest.raises(ValueError, match=re.escape("person + hair")):
segs_nodes.NH_SegsCompose().compose((_compose_frame(),), "person hair")
assert int(segs_nodes.NH_SegsCompose().compose((_compose_frame(),), "red car")[0].sum()) == 200
assert int(segs_nodes.NH_SegsCompose().compose((_compose_frame(),), "person ghost")[0].sum()) == 0
def test_segs_select_reads_vietnamese_labels_but_not_formula_words():
frame = _frame(_seg("hair", 0.9, (0, 0, 10, 10)), _seg("all", 0.8, (20, 20, 30, 30)))
picked, _, count = segs_nodes.NH_SegsSelect().select((frame,), "tóc", 0.0, 0.0, 1.0, "score", 0, 0)
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"
+47
View File
@@ -0,0 +1,47 @@
"""Vietnamese words -> the English SAM 3 and the parsers understand."""
import importlib
import unicodedata
import pytest
vi = importlib.import_module("NH-Nodes.core.vi_words")
@pytest.mark.parametrize("written, english", [
("tóc", "hair"), ("Tóc", "hair"), (" tóc ", "hair"), ("váy", "dress"), ("túi", "bag"), ("người", "person"),
("cửa sổ", "window"), ("cửa", "door"), # the longer phrase wins
("da mặt", "face skin"), ("tóc vàng", "blonde hair"), # a phrase of its own beats noun + colour
("áo đỏ", "red top"), ("áo sơ mi trắng", "white shirt"), ("tóc dài", "long hair"),
("áo xanh lá", "green top"), ("cây to", "large tree"), ("quần jean xanh dương", "blue jeans"),
("nền", "background"), ("phông nền", "background"), ("tất cả", "all"),
("balo", "backpack"), # Vietnamese written without accents, listed on purpose
("người đàn ông", "man"), ("người phụ nữ", "woman"), ("các áo", "top"), ("những chiếc xe", "vehicle"),
("áo màu đỏ", "red top"), ("nền trắng", "background"), ("phông nền xám", "background"),
])
def test_translate_known_vietnamese(written, english):
assert vi.translate(written) == (english, "translated")
def test_decomposed_accents_from_some_keyboards_are_read_the_same():
decomposed = unicodedata.normalize("NFD", "tóc")
assert decomposed != "tóc" and vi.translate(decomposed) == ("hair", "translated")
@pytest.mark.parametrize("written", ["person", "red car", "t-shirt", "*shirt", "Window", "to", "be"])
def test_english_passes_through_unchanged(written):
assert vi.translate(written) == (written, "english")
@pytest.mark.parametrize("written", ["áo xanh", "con rồng", "tóc rất đẹp"])
def test_unknown_vietnamese_is_reported_not_guessed(written):
# "xanh" alone is blue or green: the node asks instead of guessing
assert vi.translate(written) == (written, "unknown")
assert vi.is_vietnamese(written)
def test_every_listed_word_is_lower_case_composed_and_translates_to_ascii():
for table in (vi.NOUNS, vi.ADJECTIVES):
for word, english in table.items():
assert word == vi.normalize(word), word
assert english.isascii() and english == english.strip().lower(), english
+291 -13
View File
@@ -10,7 +10,9 @@ Run from the ComfyUI root:
import importlib
import os
import sys
import types
import cv2
import numpy as np
import pytest
import torch
@@ -62,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
@@ -80,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)
@@ -97,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
@@ -176,26 +178,169 @@ def test_nodes_end_to_end_with_stub_backend(monkeypatch):
labels[0:4, :] = 2 # top
labels[4:8, :] = 7 # pants
monkeypatch.setattr(vs, "load_segformer", lambda name, device: {"name": name})
monkeypatch.setattr(vs, "predict_labels", lambda bundle, img: labels)
# the label map scaled to whatever picture (whole frame or crop) the parser is shown
def parser(bundle, img, confidence=False):
scaled = cv2.resize(labels.astype(np.int32), (img.shape[1], img.shape[0]),
interpolation=cv2.INTER_NEAREST).astype(np.int64)
return (scaled, np.full(img.shape[:2], 0.9, np.float16)) if confidence else scaled
monkeypatch.setattr(vs, "predict_labels", parser)
monkeypatch.setattr(vs, "offload", lambda bundle: None)
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
def _centre_marker(bundle, img):
"""Stub parser: skin (1) everywhere and a 4 x 4 nose (2) at the centre of the picture it is shown."""
labels = np.ones(img.shape[:2], np.int64)
h, w = labels.shape
labels[h // 2 - 2:h // 2 + 2, w // 2 - 2:w // 2 + 2] = 2
return labels
def _face_parser(shown):
"""Stub face parser: hair (13) everywhere on the whole frame (the first call); on a crop, a face in its middle
(skin 1 with a nose 2 and lips 11 at the centre) and hair around it."""
def parser(bundle, img):
shown.append(img.shape[:2])
labels = np.full(img.shape[:2], 13, np.int64)
if len(shown) > 1:
h, w = labels.shape
labels[h // 4:3 * h // 4, w // 4:3 * w // 4] = 1
labels[h // 2 - 2:h // 2 + 2, w // 2 - 2:w // 2 + 2] = 2
labels[h // 2 + 3:h // 2 + 5, w // 2 - 2:w // 2 + 2] = 11
return labels
return parser
def test_portrait_labels_refine_the_whole_frame_around_each_face(monkeypatch):
shown = []
monkeypatch.setattr(vs, "predict_labels", _face_parser(shown))
monkeypatch.setattr(vs, "detect_faces", lambda img: [(40, 40, 60, 60, 0.9), (140, 40, 160, 60, 0.9)])
canvas = vs.portrait_labels({}, np.zeros((100, 200, 3), np.uint8))
# the whole frame first (a padded square), then a tight crop per face to confirm it, then a wide one per face
assert shown == [(200, 200), (44, 44), (44, 44), (72, 72), (72, 72)]
assert canvas[47, 50] == 2 and canvas[47, 150] == 2 # each face has its nose (tight crops, moved up a little)
assert canvas[95, 100] == 13 and canvas[50, 100] == 13 # outside the crops the whole-frame parse stays
def test_portrait_labels_ignore_boxes_that_are_not_faces(monkeypatch):
shown = []
monkeypatch.setattr(vs, "predict_labels", lambda bundle, img: shown.append(img.shape[:2]) or np.full(
img.shape[:2], 13, np.int64)) # the parser sees only hair in the "face" box (a hand, a pattern)
monkeypatch.setattr(vs, "detect_faces", lambda img: [(40, 40, 60, 60, 0.61)])
canvas = vs.portrait_labels({}, np.zeros((100, 200, 3), np.uint8))
assert shown == [(200, 200), (44, 44)] and (canvas == 13).all() # turned down: no wide crop, nothing pasted
def test_portrait_labels_parse_a_face_filling_the_frame_whole(monkeypatch):
shown = []
monkeypatch.setattr(vs, "predict_labels", _face_parser(shown))
monkeypatch.setattr(vs, "detect_faces", lambda img: [(20, 10, 180, 190, 0.95)]) # a close-up
vs.portrait_labels({}, np.zeros((200, 200, 3), np.uint8))
assert shown == [(200, 200)] # the same as the whole-frame parse
def test_face_detector_failure_falls_back_to_whole_frames(monkeypatch, capsys):
monkeypatch.setattr(vs, "_detection_failed", False)
def broken(img):
raise RuntimeError("Downloading opencv/face_detection_yunet failed")
monkeypatch.setattr(vs, "detect_faces", broken)
assert vs.find_faces(np.zeros((50, 50, 3), np.uint8)) == []
assert "face detector is not available" in capsys.readouterr().out
assert vs.find_faces(np.zeros((50, 50, 3), np.uint8)) == [] # not tried again, said once
assert capsys.readouterr().out == ""
def test_portrait_labels_without_a_face_parse_the_whole_frame(monkeypatch):
shown = []
monkeypatch.setattr(vs, "predict_labels", lambda bundle, img: shown.append(img.shape[:2]) or _centre_marker(
bundle, img))
looked = []
monkeypatch.setattr(vs, "detect_faces", lambda img: looked.append(img.shape[:2]) or [])
canvas = vs.portrait_labels({}, np.zeros((100, 200, 3), np.uint8))
assert looked == [(100, 200), (166, 332)] # a second look with a grey margin, for a face cut by the frame
# the whole frame, as a padded square (the parser learnt on square faces), nose at the centre of the frame
assert shown == [(200, 200)] and canvas.shape == (100, 200) and canvas[50, 100] == 2
def test_find_faces_takes_a_second_look_with_a_margin_for_faces_cut_by_the_frame(monkeypatch):
calls = []
def detector(img):
calls.append(img.shape[:2])
return [] if len(calls) == 1 else [(30.0, 40.0, 120.0, 150.0, 0.7)] # found on the padded copy
monkeypatch.setattr(vs, "detect_faces", detector)
faces = vs.find_faces(np.zeros((90, 120, 3), np.uint8))
assert calls == [(90, 120), (150, 200)] and faces == [(0.0, 10.0, 80.0, 90.0, 0.7)] # mapped back, clipped
monkeypatch.setattr(vs, "detect_faces", lambda img: [(1.0, 2.0, 3.0, 4.0, 0.9)])
assert vs.find_faces(np.zeros((90, 120, 3), np.uint8)) == [(1.0, 2.0, 3.0, 4.0, 0.9)]
def test_garment_labels_refine_a_crop_of_the_model_shape_around_the_clothes(monkeypatch):
shown = []
def parser(bundle, img, confidence=False):
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[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)
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((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] == (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 (
np.zeros(img.shape[:2], np.int64), np.ones(img.shape[:2], np.float16)))
assert not vs.garment_labels(bundle, np.zeros((200, 300, 3), np.uint8)).any() and shown == [1] # no clothes
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
@@ -203,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")
@@ -233,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)