From 643c253a8fce2900993842584daeb330ee96a464 Mon Sep 17 00:00:00 2001 From: drphero <14174687+drphero@users.noreply.github.com> Date: Thu, 29 Jan 2026 22:31:58 +0100 Subject: [PATCH] Initial commit --- .gitignore | 2 + LICENSE | 191 ++++++ README.md | 74 +++ __init__.py | 3 + fashn_vton/__init__.py | 13 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 399 bytes .../__pycache__/pipeline.cpython-312.pyc | Bin 0 -> 17112 bytes .../__pycache__/tryon_mmdit.cpython-312.pyc | Bin 0 -> 32528 bytes fashn_vton/dwpose/__init__.py | 16 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 724 bytes .../dwpose/__pycache__/dwpose.cpython-312.pyc | Bin 0 -> 5912 bytes .../__pycache__/onnxdet.cpython-312.pyc | Bin 0 -> 7627 bytes .../__pycache__/onnxpose.cpython-312.pyc | Bin 0 -> 15650 bytes .../dwpose/__pycache__/utils.cpython-312.pyc | Bin 0 -> 11513 bytes .../__pycache__/wholebody.cpython-312.pyc | Bin 0 -> 2959 bytes fashn_vton/dwpose/dwpose.py | 131 ++++ fashn_vton/dwpose/onnxdet.py | 131 ++++ fashn_vton/dwpose/onnxpose.py | 364 +++++++++++ fashn_vton/dwpose/utils.py | 227 +++++++ fashn_vton/dwpose/wholebody.py | 51 ++ fashn_vton/pipeline.py | 343 +++++++++++ fashn_vton/preprocessing/__init__.py | 22 + .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 564 bytes .../__pycache__/agnostic.cpython-312.pyc | Bin 0 -> 8091 bytes .../__pycache__/masks.cpython-312.pyc | Bin 0 -> 7590 bytes .../__pycache__/transforms.cpython-312.pyc | Bin 0 -> 11237 bytes fashn_vton/preprocessing/agnostic.py | 212 +++++++ fashn_vton/preprocessing/masks.py | 163 +++++ fashn_vton/preprocessing/transforms.py | 222 +++++++ fashn_vton/tryon_mmdit.py | 563 ++++++++++++++++++ fashn_vton/utils/__init__.py | 40 ++ .../__pycache__/__init__.cpython-312.pyc | Bin 0 -> 1031 bytes .../__pycache__/checkpoint.cpython-312.pyc | Bin 0 -> 1805 bytes .../utils/__pycache__/common.cpython-312.pyc | Bin 0 -> 1720 bytes .../__pycache__/keypoints.cpython-312.pyc | Bin 0 -> 918 bytes .../utils/__pycache__/logger.cpython-312.pyc | Bin 0 -> 2724 bytes .../__pycache__/sampling.cpython-312.pyc | Bin 0 -> 1953 bytes .../utils/__pycache__/tensor.cpython-312.pyc | Bin 0 -> 3085 bytes fashn_vton/utils/checkpoint.py | 44 ++ fashn_vton/utils/common.py | 33 + fashn_vton/utils/keypoints.py | 18 + fashn_vton/utils/logger.py | 55 ++ fashn_vton/utils/sampling.py | 43 ++ fashn_vton/utils/tensor.py | 77 +++ nodes.py | 153 +++++ pyproject.toml | 17 + requirements.txt | 10 + 47 files changed, 3218 insertions(+) create mode 100644 .gitignore create mode 100644 LICENSE create mode 100644 README.md create mode 100644 __init__.py create mode 100644 fashn_vton/__init__.py create mode 100644 fashn_vton/__pycache__/__init__.cpython-312.pyc create mode 100644 fashn_vton/__pycache__/pipeline.cpython-312.pyc create mode 100644 fashn_vton/__pycache__/tryon_mmdit.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__init__.py create mode 100644 fashn_vton/dwpose/__pycache__/__init__.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__pycache__/dwpose.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__pycache__/onnxdet.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__pycache__/onnxpose.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__pycache__/utils.cpython-312.pyc create mode 100644 fashn_vton/dwpose/__pycache__/wholebody.cpython-312.pyc create mode 100644 fashn_vton/dwpose/dwpose.py create mode 100644 fashn_vton/dwpose/onnxdet.py create mode 100644 fashn_vton/dwpose/onnxpose.py create mode 100644 fashn_vton/dwpose/utils.py create mode 100644 fashn_vton/dwpose/wholebody.py create mode 100644 fashn_vton/pipeline.py create mode 100644 fashn_vton/preprocessing/__init__.py create mode 100644 fashn_vton/preprocessing/__pycache__/__init__.cpython-312.pyc create mode 100644 fashn_vton/preprocessing/__pycache__/agnostic.cpython-312.pyc create mode 100644 fashn_vton/preprocessing/__pycache__/masks.cpython-312.pyc create mode 100644 fashn_vton/preprocessing/__pycache__/transforms.cpython-312.pyc create mode 100644 fashn_vton/preprocessing/agnostic.py create mode 100644 fashn_vton/preprocessing/masks.py create mode 100644 fashn_vton/preprocessing/transforms.py create mode 100644 fashn_vton/tryon_mmdit.py create mode 100644 fashn_vton/utils/__init__.py create mode 100644 fashn_vton/utils/__pycache__/__init__.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/checkpoint.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/common.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/keypoints.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/logger.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/sampling.cpython-312.pyc create mode 100644 fashn_vton/utils/__pycache__/tensor.cpython-312.pyc create mode 100644 fashn_vton/utils/checkpoint.py create mode 100644 fashn_vton/utils/common.py create mode 100644 fashn_vton/utils/keypoints.py create mode 100644 fashn_vton/utils/logger.py create mode 100644 fashn_vton/utils/sampling.py create mode 100644 fashn_vton/utils/tensor.py create mode 100644 nodes.py create mode 100644 pyproject.toml create mode 100644 requirements.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..9923533 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +.DS_Store +.DS_Store ? \ No newline at end of file diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..d095272 --- /dev/null +++ b/LICENSE @@ -0,0 +1,191 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to the Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no theory of + liability, whether in contract, strict liability, or tort + (including negligence or otherwise) arising in any way out of + the use or inability to use the Work (even if such Holder or other + party has been advised of the possibility of such damages), shall + any Contributor be liable to You for damages, including any direct, + indirect, special, incidental, or consequential damages of any + character arising as a result of this License or out of the use or + inability to use the Work (including but not limited to damages for + loss of goodwill, work stoppage, computer failure or malfunction, or + any and all other commercial damages or losses), even if such + Contributor has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + Copyright 2025 FASHN AI + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..7e1db6b --- /dev/null +++ b/README.md @@ -0,0 +1,74 @@ +# ComfyUI FASHN VTON v1.5 Custom Nodes + +This custom node set implements the [FASHN VTON v1.5](https://github.com/fashn-AI/fashn-vton-1.5) model for virtual try-on in ComfyUI. + +## Installation + +1. Copy the `ComfyUI-FASHN-VTON` folder into your ComfyUI `custom_nodes` directory. +2. Install the required dependencies: + ```bash + pip install -r requirements.txt + ``` + Note: If you are using a portable version of ComfyUI, use the corresponding python executable. + +## How to Use + +### 1. (Down)load FASHN VTON + +Use the **(Down)load FASHN VTON** node. + +The model **downloads automatically on first use**. In most cases, **no manual download is required**. + +When this node runs, it will: +- Download the **FASHN VTON v1.5** model weights +- Download the required **DWPose** models +- Store everything under `ComfyUI/models/fashn-vton/` +- Load the pipeline automatically + +If the files already exist locally, the download step is skipped. + +#### Automatic Downloads + +The following files are fetched automatically from Hugging Face: + +- **FASHN VTON model** + - `model.safetensors` + - Source: https://huggingface.co/fashn-ai/fashn-vton-1.5 + +- **DWPose models** + - `yolox_l.onnx` + - `dw-ll_ucoco_384.onnx` + - Source: https://huggingface.co/fashn-ai/DWPose + +#### Manual Download (Optional) + +If you prefer to download the models manually (e.g. for offline use), place the files in the following directory structure: + +``` +ComfyUI/ +└── models/ + └── fashn-vton/ + ├── model.safetensors + └── dwpose/ + ├── yolox_l.onnx + └── dw-ll_ucoco_384.onnx +``` + +### 2. Inference + +Use the **FASHN VTON Inference** node: +- **pipeline**: Connect from the Loader node. +- **person_image**: The image of the person. +- **garment_image**: The image of the garment. +- **category**: `tops`, `bottoms`, or `one-pieces`. +- **num_timesteps**: Recommended 30–50. +- **guidance_scale**: Recommended 1.5–3.0. +- **keep_model_loaded**: If set to `false`, the model will be moved to CPU after each inference to save VRAM. + +## Progress Bar + +The inference node supports the ComfyUI native progress bar to show the status of the sampling process. + +## Credits + +Model by [FASHN AI](https://fashn.ai/). Implementation based on their open-source repository. diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..39a8c6b --- /dev/null +++ b/__init__.py @@ -0,0 +1,3 @@ +from .nodes import NODE_CLASS_MAPPINGS, NODE_DISPLAY_NAME_MAPPINGS + +__all__ = ["NODE_CLASS_MAPPINGS", "NODE_DISPLAY_NAME_MAPPINGS"] diff --git a/fashn_vton/__init__.py b/fashn_vton/__init__.py new file mode 100644 index 0000000..a3204a1 --- /dev/null +++ b/fashn_vton/__init__.py @@ -0,0 +1,13 @@ +"""FASHN VTON v1.5""" + +__version__ = "1.5.0" + +from .pipeline import PipelineOutput, TryOnPipeline +from .tryon_mmdit import TryOnModel + +__all__ = [ + "TryOnPipeline", + "PipelineOutput", + "TryOnModel", + "__version__", +] diff --git a/fashn_vton/__pycache__/__init__.cpython-312.pyc b/fashn_vton/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..9d8a700107abe795147b3d6ffb5bf604045f9c93 GIT binary patch literal 399 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

`&Q z&~_ts!}JOKxFI?d<@kQoqiMCC27?lpm=))CkD)5SH%z|>xF2ExWW{Hq@KFRk1rz`> z#HGUPC1~s5%o~IKB!mHuBbs{!Y0&IpTfzk#Y-FhB6Jk84yuj#{D6ULpmx^n^FnwkQ z>{SaKILNB+2XP8`dvrVug9qFKt#!qNLf+&G0E>km1F+goy_SptZm{R|^#J^tye_EC$m)QH0@As{fnSLRi9#WR!i z8T{y@>^KnM`y@ar^<>!0LP`aH2B!F%!29@rixFO^2#ka1i)EG%FA_8u&|~;AVwOpy zWHa2?5M*FwKe+fI>H_z@;vEhE7%)>J(2MKKM3UFwSi!d^&LZZ+6yFApii8OqqiLzTp{a#uSqyQm4rJa96SDp*z(&DX&^A9DO3dU^7Qx~ME^ox zeo0Jg7n0AnbAYu1U_B+2U66DD5Z9{ZrQe%1)j;46idWNW7sZ=!MZfjxLRRq9#;!?jfP=VSo2;VbI$y;W3`$3qAa2Ck^?rrKpee0T#tKXNe@0aTPH^RC4 zr!r#?>O&jtx%$4$SfSpvYFIJczP$SNm9OXO+w=83Qhm=xa5KE++8WE%AA=^IhRkVV zmLOo?QWHqc1Rh{bR4NeUl7rTG?ZCi5$nph1TN)=%Yy@Z?ShAifG76Sns#V0P0KTnw z9}9&PRL}<2B4Et0V*wRIC4wKv2$d1pSysou>d1Q7?6 z&;hA8ke8DP6V-xpWWh&RIh1>Sgq3m$hzb7)aMNIG>T;o{AhG=HT`t(Q6!Ov+dj0p?n zK*V8muD_Z+bRvJ~jCAPChwaj#$?VkY`6*VKVzbpJyY=PD|q+s zYT7)o*()`V035x~nl-oW)Kd+deaX9;x$y~>I zXjlle<^z3Fpl^fu`HB2wM4F6b1AVywOD3NW_DjM3?7*>~NAgpgG{t3u{kh-_Sucad z`=0)X)_1Mo9B1*~TljJM{q#Rz@{yhL?pr^SYv{{ZKK6HKdyeP)&t>X9ZaJ`VA=fgJ zH3vR!?aTI$=30+|>kh1j!+mS==HyyK-ajb$2RF@<|Jj^ch8k_OIvduS@pVvrHt<%uCFC&VCi#@+`p}D+RWL$(6}OOjVGS@JYu@wB> zd4I3u??uDGgfujf_4nrdCo^@0mce|>K?%$R4f$u!OV6GMi$Shs3Ib7P~j{?-l^SYet`JX<$dE$0w-rXs=A?}sjy&H#q z;U3(2I%CSZp5JA;UmJceveEUgudctg*`4(dZ<~+)#@Vv|(zdfNYwr8~rxSW={~NmB zf11`&?h`rz5%T}`bp6F6hW|--TpTro^ixwIGyfxGJ2s4NV6>=aKk0?k<*87RN5dpf zV}yVtkKBp>9~jMJgjy6o3XyD@Bp>JUm$Bp&MyD}CjDWZ`D8jT(6W`5__*KS^jb!bVuVpp8lHPHU zY#r6oad-00nUGw$3>EEXt?7c$p)af<0Z*Pir%-=M3O$RobC8YGC+BPYe!&n z?IeZF2J4VdFd|?W2t-Q5UOR{Fl6UYXhmE6NS7E)lPS~|0x&q&Qjc5I}; zPBr>>93)wX6LgYfJQ#0$ZeijnCfl@+{=v5d1SjlluuXyHscc5F1<9`e8>}ZFlHv_ybkdL#7by_)1)OF{I1>M334Z}n!(Li7luokmJ;`>no4*SbSN1g}SL+rjK_ P?JuxQWu~k(x9uP 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 Ga^b&v`Qcdr literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/__pycache__/dwpose.cpython-312.pyc b/fashn_vton/dwpose/__pycache__/dwpose.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..3b2944757cf8d6a9ce73c7e1fac17b5850e257d7 GIT binary patch literal 5912 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 literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/__pycache__/onnxdet.cpython-312.pyc b/fashn_vton/dwpose/__pycache__/onnxdet.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..7969252628a50641581faaa5013671dd2e33767a GIT binary patch literal 7627 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 B*x>*G literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/__pycache__/onnxpose.cpython-312.pyc b/fashn_vton/dwpose/__pycache__/onnxpose.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..ee4d3213db724a2f45774bb2e401f4266f704fe8 GIT binary patch literal 15650 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 VJ~NhiaU6$e+oN4pb{moL{{=-Gd58c2 literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/__pycache__/utils.cpython-312.pyc b/fashn_vton/dwpose/__pycache__/utils.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8b30ceb2b422e6b88f0b24157baad614a686b567 GIT binary patch literal 11513 zcmd5?Yit`=cAnw;{T%8+$+9d(FTWHiiS0N^y_6-t5!~9^iCVZwfP()NJLr%8*`9OB;fyHS zPSSL{7vMK{&b@PAbI(2BIm3T4n++5s#f?k;f2^XYukc1pihSnr8<4q5u~aw3(ySst zchlsm=vI)cvRg^6s%|x_?AEZVcc^YHs|M7u8bCd(1vIcaKqIRMG)*g73>PmLIyvv8 zKR8f39`OhK5x*~7>t(&85g%LI&xJ;6JI}lk3j1oCJ)_>fLEpCa?a#ODHVj50qv74H ztponZ;CS!$zR*bP!Okwvw&T9A&&%}Y z$4eTV8BYzNv?!g2`A3yeRiyCbkgBK(-177-Iwy_N�*=)FkURfT#piuqr?ms|HlE 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 WS^-(Q4)e4D&|O129(Q1t^zc7_8IX7Y literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/__pycache__/wholebody.cpython-312.pyc b/fashn_vton/dwpose/__pycache__/wholebody.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..8cc2af05d8c29cce888bcd121e25883d38cfb82e GIT binary patch literal 2959 zcmbVOPfQ!x8J`)C|MMph2soI;Y$%JD#fHEp*@$j7N`Pg9NZ4(N;-))wX2vkYp7FjJ z2TX0WsYFo+w0<=jomGsv%)^MN zrJ@rzN3ZvPBI|OTh;vaZ%2&lDr>ZQ_FiV>iH6asO6=lI%Oe3IeIMQMrqE)3u4@+ha z&Vszg$P8?piE$Dyu}@goJoki&@mWXMNxik*h>l|!oWP@e@KdN^jmdHISj&kau`dp_ zBx=;6!55=X42iRSB}V{kwv>nSH{kxPk#pF3g`H*dyyOJxRGpFwXNk%4hR61uH}Bt; zta-lvKV_71?9myU=} zb&J6cr**b~jUDz-UYC;z+6eDRe3XVHhAv17p)TtTNF5w8X+nZmLOr-OGmqEgFo}+B z6FMCKn2s)Jwtjzkzx8EF<~9 zX7e7H>tOcg+*a0{$J%#I_9n~~a_+nn^3;Qu8+K-%4ag06MtMH(z%x&9pEBXbsW2Cr zB7V@r=ZKFlDtZ-fxQbUx@E)t!5LK*XcE}vt*Q&Vla3kr1iS(iw8>)&}SH;gj74N%> zS60Pyuj13GBC1yrcu5!urY7&84mn6>DooRRw#m#*A}GF#B#NXT$pDfeB!ftXkz7QA zB}HIM<|1J~!MaHY65JID0->CmrhZXV#ke7>@S}W6jstonwOK`sCxy5wQCD0yvT2!| z#sHV1>M}9HKGJE58c`-vv(yJLClFcBBn|3)BxfyDqb^<4Kua5HDnONlMtK#Gk2b6F zf^fp(OQjHu;5)EL>$J42+LDe(Y9n4KMBc*X0i~eI8Qj$nW-E zsq|ji?AdO;UhwS)-zg67_Kj5fMmFca>DdlWS3H4&cjeOSK*#fQ&(0MCrLoQTE5oDZ z-mz~!*$Lb!G`wzX`q{$Mg_Y&6CxwmAK;NN^q$3+16O)y<01n3f@=lK}cy*CW(BVAbg&QCoK!P08wNV z$S)Z)gLxkWJKiMN+&Xss6Xt+B<@6nNH#xg3NA!V@tXmKyO%w!j24w0~GpTfzTteQD zUpbOd4J9QLe5KTl8t5>yg{|))wuRNl=9&}Wc2o!Tj96uVKyHHpAzl9-kiR%smi;T| iVEF?d!}`8un*YEA{=~ffN2dGlu5PyLZwwGK&A$PcAlRG$ literal 0 HcmV?d00001 diff --git a/fashn_vton/dwpose/dwpose.py b/fashn_vton/dwpose/dwpose.py new file mode 100644 index 0000000..1e2c21a --- /dev/null +++ b/fashn_vton/dwpose/dwpose.py @@ -0,0 +1,131 @@ +import numpy as np +import torch + +from .utils import ( + draw_bodypose, + draw_bodypose_gray, + draw_facepose, + draw_facepose_gray, + draw_handpose, + draw_handpose_gray, +) +from .wholebody import Wholebody + +__all__ = ["DWposeDetector", "draw_pose"] + +# Minimum confidence threshold for keypoint visibility +KEYPOINT_VISIBILITY_THRESHOLD = 0.3 + +def draw_pose(pose, H, W, canvas_value: int = 0, grayscale: bool = False): + bodies = pose["bodies"] + candidate = bodies["candidate"] + subset = bodies["subset"] + + if grayscale: + draw_bodypose_fn = draw_bodypose_gray + draw_handpose_fn = draw_handpose_gray + draw_facepose_fn = draw_facepose_gray + canvas = np.full((H, W), canvas_value, dtype=np.uint8) + else: + draw_bodypose_fn = draw_bodypose + draw_handpose_fn = draw_handpose + draw_facepose_fn = draw_facepose + canvas_value = int(canvas_value / 0.6) if canvas_value > 0 else 0 + canvas = np.full((H, W, 3), canvas_value, dtype=np.uint8) + + canvas = draw_bodypose_fn(canvas, candidate, subset) + + if "hands" in pose: + canvas = draw_handpose_fn(canvas, pose.get("hands")) + if "faces" in pose: + canvas = draw_facepose_fn(canvas, pose.get("faces")) + + return canvas + + +class DWposeDetector: + def __init__( + self, + checkpoints_dir, + device="cuda:0", + ): + + self.pose_estimation = Wholebody(checkpoints_dir=checkpoints_dir, device=device) + + def _find_best_candidate(self, subset, candidate, score_threshold=KEYPOINT_VISIBILITY_THRESHOLD): + # Apply score threshold to subset keypoints + valid_keypoints = subset[:, 1:14] > score_threshold + + # Calculate scores for each candidate, only counting valid keypoints + headless_scores = np.sum(subset[:, 1:14] * valid_keypoints, axis=1) + + # Extract keypoints for each candidate, excluding the head + headless_keypoints = candidate[:, 1:14] + + def compute_area(keypoints): + # Filter keypoints based on the valid_keypoints mask + valid_kp = keypoints[ + valid_keypoints[0] + ] # Assuming all candidates have the same validity mask for simplicity + valid_x, valid_y = valid_kp[:, 0][valid_kp[:, 0] > 0], valid_kp[:, 1][valid_kp[:, 1] > 0] + if not len(valid_x) or not len(valid_y): + return 0 + return (np.max(valid_x) - np.min(valid_x)) * (np.max(valid_y) - np.min(valid_y)) + + areas = [compute_area(kp) for kp in headless_keypoints] + + # Here, we multiply scores by areas, but we need to handle division by zero or invalid calculations + with np.errstate(divide="ignore", invalid="ignore"): + scores_times_areas = headless_scores * np.array(areas) + + # Replace NaN or inf with 0 for np.nanargmax to work correctly + scores_times_areas[np.isnan(scores_times_areas) | np.isinf(scores_times_areas)] = 0 + + # If all scores are zero (or invalid), we might want to handle this case differently + if np.all(scores_times_areas == 0): + best_candidate_idx = np.argmax(headless_scores) + else: + best_candidate_idx = np.nanargmax(scores_times_areas) + + return ( + candidate[best_candidate_idx : best_candidate_idx + 1], + subset[best_candidate_idx : best_candidate_idx + 1], + ) + + @torch.inference_mode() + def __call__(self, oriImg: np.array, single: bool = True) -> dict: + oriImg = oriImg.copy() + H, W, C = oriImg.shape + + candidate, subset = self.pose_estimation(oriImg) + nums, keys, locs = candidate.shape + + if single and nums > 1: + candidate, subset = self._find_best_candidate(subset, candidate) + nums = 1 # Now we only have one candidate + + candidate[..., 0] /= float(W) + candidate[..., 1] /= float(H) + + body = candidate[:, :18].copy() + body = body.reshape(nums * 18, locs) + score = subset[:, :18] + for i in range(len(score)): + for j in range(len(score[i])): + if score[i][j] > KEYPOINT_VISIBILITY_THRESHOLD: + score[i][j] = int(18 * i + j) + else: + score[i][j] = -1 + + un_visible = subset < KEYPOINT_VISIBILITY_THRESHOLD + candidate[un_visible] = -1 + + foot = candidate[:, 18:24] + faces = candidate[:, 24:92] + hands = candidate[:, 92:113] + hands = np.vstack([hands, candidate[:, 113:]]) + + bodies = dict(candidate=body, subset=score) + pose = dict(bodies=bodies, hands=hands, faces=faces) + + return pose diff --git a/fashn_vton/dwpose/onnxdet.py b/fashn_vton/dwpose/onnxdet.py new file mode 100644 index 0000000..e2b0abd --- /dev/null +++ b/fashn_vton/dwpose/onnxdet.py @@ -0,0 +1,131 @@ +""" +Detection utilities adapted from YOLOX (Apache-2.0): +https://github.com/Megvii-BaseDetection/YOLOX +""" + +import cv2 +import numpy as np + + +def nms(boxes, scores, nms_thr): + """Single class NMS implemented in Numpy.""" + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + order = scores.argsort()[::-1] + + keep = [] + while order.size > 0: + i = order[0] + keep.append(i) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + + w = np.maximum(0.0, xx2 - xx1 + 1) + h = np.maximum(0.0, yy2 - yy1 + 1) + inter = w * h + ovr = inter / (areas[i] + areas[order[1:]] - inter) + + inds = np.where(ovr <= nms_thr)[0] + order = order[inds + 1] + + return keep + + +def multiclass_nms(boxes, scores, nms_thr, score_thr): + """Multiclass NMS implemented in Numpy. Class-aware version.""" + final_dets = [] + num_classes = scores.shape[1] + for cls_ind in range(num_classes): + cls_scores = scores[:, cls_ind] + valid_score_mask = cls_scores > score_thr + if valid_score_mask.sum() == 0: + continue + else: + valid_scores = cls_scores[valid_score_mask] + valid_boxes = boxes[valid_score_mask] + keep = nms(valid_boxes, valid_scores, nms_thr) + if len(keep) > 0: + cls_inds = np.ones((len(keep), 1)) * cls_ind + dets = np.concatenate([valid_boxes[keep], valid_scores[keep, None], cls_inds], 1) + final_dets.append(dets) + if len(final_dets) == 0: + return None + return np.concatenate(final_dets, 0) + + +def demo_postprocess(outputs, img_size, p6=False): + grids = [] + expanded_strides = [] + strides = [8, 16, 32] if not p6 else [8, 16, 32, 64] + + hsizes = [img_size[0] // stride for stride in strides] + wsizes = [img_size[1] // stride for stride in strides] + + for hsize, wsize, stride in zip(hsizes, wsizes, strides): + xv, yv = np.meshgrid(np.arange(wsize), np.arange(hsize)) + grid = np.stack((xv, yv), 2).reshape(1, -1, 2) + grids.append(grid) + shape = grid.shape[:2] + expanded_strides.append(np.full((*shape, 1), stride)) + + grids = np.concatenate(grids, 1) + expanded_strides = np.concatenate(expanded_strides, 1) + outputs[..., :2] = (outputs[..., :2] + grids) * expanded_strides + outputs[..., 2:4] = np.exp(outputs[..., 2:4]) * expanded_strides + + return outputs + + +def preprocess(img, input_size, swap=(2, 0, 1)): + if len(img.shape) == 3: + padded_img = np.ones((input_size[0], input_size[1], 3), dtype=np.uint8) * 114 + else: + padded_img = np.ones(input_size, dtype=np.uint8) * 114 + + r = min(input_size[0] / img.shape[0], input_size[1] / img.shape[1]) + resized_img = cv2.resize( + img, + (int(img.shape[1] * r), int(img.shape[0] * r)), + interpolation=cv2.INTER_LINEAR, + ).astype(np.uint8) + padded_img[: int(img.shape[0] * r), : int(img.shape[1] * r)] = resized_img + + padded_img = padded_img.transpose(swap) + padded_img = np.ascontiguousarray(padded_img, dtype=np.float32) + return padded_img, r + + +def inference_detector(session, oriImg): + input_shape = (640, 640) + img, ratio = preprocess(oriImg, input_shape) + + ort_inputs = {session.get_inputs()[0].name: img[None, :, :, :]} + output = session.run(None, ort_inputs) + predictions = demo_postprocess(output[0], input_shape)[0] + + boxes = predictions[:, :4] + scores = predictions[:, 4:5] * predictions[:, 5:] + + boxes_xyxy = np.ones_like(boxes) + boxes_xyxy[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0 + boxes_xyxy[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0 + boxes_xyxy[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0 + boxes_xyxy[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0 + boxes_xyxy /= ratio + dets = multiclass_nms(boxes_xyxy, scores, nms_thr=0.45, score_thr=0.1) + if dets is not None: + final_boxes, final_scores, final_cls_inds = dets[:, :4], dets[:, 4], dets[:, 5] + isscore = final_scores > 0.3 + iscat = final_cls_inds == 0 + isbbox = [i and j for (i, j) in zip(isscore, iscat)] + final_boxes = final_boxes[isbbox] + else: + final_boxes = np.array([]) + + return final_boxes diff --git a/fashn_vton/dwpose/onnxpose.py b/fashn_vton/dwpose/onnxpose.py new file mode 100644 index 0000000..a5c1ff2 --- /dev/null +++ b/fashn_vton/dwpose/onnxpose.py @@ -0,0 +1,364 @@ +""" +Pose estimation adapted from DWPose/MMPose (Apache-2.0): +https://github.com/IDEA-Research/DWPose +""" + +from typing import List, Tuple + +import cv2 +import numpy as np +import onnxruntime as ort + + +def preprocess( + img: np.ndarray, out_bbox, input_size: Tuple[int, int] = (192, 256) +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Do preprocessing for RTMPose model inference. + + Args: + img (np.ndarray): Input image in shape. + input_size (tuple): Input image size in shape (w, h). + + Returns: + tuple: + - resized_img (np.ndarray): Preprocessed image. + - center (np.ndarray): Center of image. + - scale (np.ndarray): Scale of image. + """ + # get shape of image + img_shape = img.shape[:2] + out_img, out_center, out_scale = [], [], [] + if len(out_bbox) == 0: + out_bbox = [[0, 0, img_shape[1], img_shape[0]]] + for i in range(len(out_bbox)): + x0 = out_bbox[i][0] + y0 = out_bbox[i][1] + x1 = out_bbox[i][2] + y1 = out_bbox[i][3] + bbox = np.array([x0, y0, x1, y1]) + + # get center and scale + center, scale = bbox_xyxy2cs(bbox, padding=1.25) + + # do affine transformation + resized_img, scale = top_down_affine(input_size, scale, center, img) + + # normalize image + mean = np.array([123.675, 116.28, 103.53]) + std = np.array([58.395, 57.12, 57.375]) + resized_img = (resized_img - mean) / std + + out_img.append(resized_img) + out_center.append(center) + out_scale.append(scale) + + return out_img, out_center, out_scale + + +def inference(sess: ort.InferenceSession, img: np.ndarray) -> np.ndarray: + """Inference RTMPose model. + + Args: + sess (ort.InferenceSession): ONNXRuntime session. + img (np.ndarray): Input image in shape. + + Returns: + outputs (np.ndarray): Output of RTMPose model. + """ + all_out = [] + # build input + for i in range(len(img)): + input = [img[i].transpose(2, 0, 1)] + + # build output + sess_input = {sess.get_inputs()[0].name: input} + sess_output = [] + for out in sess.get_outputs(): + sess_output.append(out.name) + + # run model + outputs = sess.run(sess_output, sess_input) + all_out.append(outputs) + + return all_out + + +def postprocess( + outputs: List[np.ndarray], + model_input_size: Tuple[int, int], + center: Tuple[int, int], + scale: Tuple[int, int], + simcc_split_ratio: float = 2.0, +) -> Tuple[np.ndarray, np.ndarray]: + """Postprocess for RTMPose model output. + + Args: + outputs (np.ndarray): Output of RTMPose model. + model_input_size (tuple): RTMPose model Input image size. + center (tuple): Center of bbox in shape (x, y). + scale (tuple): Scale of bbox in shape (w, h). + simcc_split_ratio (float): Split ratio of simcc. + + Returns: + tuple: + - keypoints (np.ndarray): Rescaled keypoints. + - scores (np.ndarray): Model predict scores. + """ + all_key = [] + all_score = [] + for i in range(len(outputs)): + # use simcc to decode + simcc_x, simcc_y = outputs[i] + keypoints, scores = decode(simcc_x, simcc_y, simcc_split_ratio) + + # rescale keypoints + keypoints = keypoints / model_input_size * scale[i] + center[i] - scale[i] / 2 + all_key.append(keypoints[0]) + all_score.append(scores[0]) + + return np.array(all_key), np.array(all_score) + + +def bbox_xyxy2cs(bbox: np.ndarray, padding: float = 1.0) -> Tuple[np.ndarray, np.ndarray]: + """Transform the bbox format from (x,y,w,h) into (center, scale) + + Args: + bbox (ndarray): Bounding box(es) in shape (4,) or (n, 4), formatted + as (left, top, right, bottom) + padding (float): BBox padding factor that will be multilied to scale. + Default: 1.0 + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: Center (x, y) of the bbox in shape (2,) or + (n, 2) + - np.ndarray[float32]: Scale (w, h) of the bbox in shape (2,) or + (n, 2) + """ + # convert single bbox from (4, ) to (1, 4) + dim = bbox.ndim + if dim == 1: + bbox = bbox[None, :] + + # get bbox center and scale + x1, y1, x2, y2 = np.hsplit(bbox, [1, 2, 3]) + center = np.hstack([x1 + x2, y1 + y2]) * 0.5 + scale = np.hstack([x2 - x1, y2 - y1]) * padding + + if dim == 1: + center = center[0] + scale = scale[0] + + return center, scale + + +def _fix_aspect_ratio(bbox_scale: np.ndarray, aspect_ratio: float) -> np.ndarray: + """Extend the scale to match the given aspect ratio. + + Args: + scale (np.ndarray): The image scale (w, h) in shape (2, ) + aspect_ratio (float): The ratio of ``w/h`` + + Returns: + np.ndarray: The reshaped image scale in (2, ) + """ + w, h = np.hsplit(bbox_scale, [1]) + bbox_scale = np.where(w > h * aspect_ratio, np.hstack([w, w / aspect_ratio]), np.hstack([h * aspect_ratio, h])) + return bbox_scale + + +def _rotate_point(pt: np.ndarray, angle_rad: float) -> np.ndarray: + """Rotate a point by an angle. + + Args: + pt (np.ndarray): 2D point coordinates (x, y) in shape (2, ) + angle_rad (float): rotation angle in radian + + Returns: + np.ndarray: Rotated point in shape (2, ) + """ + sn, cs = np.sin(angle_rad), np.cos(angle_rad) + rot_mat = np.array([[cs, -sn], [sn, cs]]) + return rot_mat @ pt + + +def _get_3rd_point(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """To calculate the affine matrix, three pairs of points are required. This + function is used to get the 3rd point, given 2D points a & b. + + The 3rd point is defined by rotating vector `a - b` by 90 degrees + anticlockwise, using b as the rotation center. + + Args: + a (np.ndarray): The 1st point (x,y) in shape (2, ) + b (np.ndarray): The 2nd point (x,y) in shape (2, ) + + Returns: + np.ndarray: The 3rd point. + """ + direction = a - b + c = b + np.r_[-direction[1], direction[0]] + return c + + +def get_warp_matrix( + center: np.ndarray, + scale: np.ndarray, + rot: float, + output_size: Tuple[int, int], + shift: Tuple[float, float] = (0.0, 0.0), + inv: bool = False, +) -> np.ndarray: + """Calculate the affine transformation matrix that can warp the bbox area + in the input image to the output size. + + Args: + center (np.ndarray[2, ]): Center of the bounding box (x, y). + scale (np.ndarray[2, ]): Scale of the bounding box + wrt [width, height]. + rot (float): Rotation angle (degree). + output_size (np.ndarray[2, ] | list(2,)): Size of the + destination heatmaps. + shift (0-100%): Shift translation ratio wrt the width/height. + Default (0., 0.). + inv (bool): Option to inverse the affine transform direction. + (inv=False: src->dst or inv=True: dst->src) + + Returns: + np.ndarray: A 2x3 transformation matrix + """ + shift = np.array(shift) + src_w = scale[0] + dst_w = output_size[0] + dst_h = output_size[1] + + # compute transformation matrix + rot_rad = np.deg2rad(rot) + src_dir = _rotate_point(np.array([0.0, src_w * -0.5]), rot_rad) + dst_dir = np.array([0.0, dst_w * -0.5]) + + # get four corners of the src rectangle in the original image + src = np.zeros((3, 2), dtype=np.float32) + src[0, :] = center + scale * shift + src[1, :] = center + src_dir + scale * shift + src[2, :] = _get_3rd_point(src[0, :], src[1, :]) + + # get four corners of the dst rectangle in the input image + dst = np.zeros((3, 2), dtype=np.float32) + dst[0, :] = [dst_w * 0.5, dst_h * 0.5] + dst[1, :] = np.array([dst_w * 0.5, dst_h * 0.5]) + dst_dir + dst[2, :] = _get_3rd_point(dst[0, :], dst[1, :]) + + if inv: + warp_mat = cv2.getAffineTransform(np.float32(dst), np.float32(src)) + else: + warp_mat = cv2.getAffineTransform(np.float32(src), np.float32(dst)) + + return warp_mat + + +def top_down_affine( + input_size: dict, bbox_scale: dict, bbox_center: dict, img: np.ndarray +) -> Tuple[np.ndarray, np.ndarray]: + """Get the bbox image as the model input by affine transform. + + Args: + input_size (dict): The input size of the model. + bbox_scale (dict): The bbox scale of the img. + bbox_center (dict): The bbox center of the img. + img (np.ndarray): The original image. + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: img after affine transform. + - np.ndarray[float32]: bbox scale after affine transform. + """ + w, h = input_size + warp_size = (int(w), int(h)) + + # reshape bbox to fixed aspect ratio + bbox_scale = _fix_aspect_ratio(bbox_scale, aspect_ratio=w / h) + + # get the affine matrix + center = bbox_center + scale = bbox_scale + rot = 0 + warp_mat = get_warp_matrix(center, scale, rot, output_size=(w, h)) + + # do affine transform + img = cv2.warpAffine(img, warp_mat, warp_size, flags=cv2.INTER_LINEAR) + + return img, bbox_scale + + +def get_simcc_maximum(simcc_x: np.ndarray, simcc_y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Get maximum response location and value from simcc representations. + + Note: + instance number: N + num_keypoints: K + heatmap height: H + heatmap width: W + + Args: + simcc_x (np.ndarray): x-axis SimCC in shape (K, Wx) or (N, K, Wx) + simcc_y (np.ndarray): y-axis SimCC in shape (K, Wy) or (N, K, Wy) + + Returns: + tuple: + - locs (np.ndarray): locations of maximum heatmap responses in shape + (K, 2) or (N, K, 2) + - vals (np.ndarray): values of maximum heatmap responses in shape + (K,) or (N, K) + """ + N, K, Wx = simcc_x.shape + simcc_x = simcc_x.reshape(N * K, -1) + simcc_y = simcc_y.reshape(N * K, -1) + + # get maximum value locations + x_locs = np.argmax(simcc_x, axis=1) + y_locs = np.argmax(simcc_y, axis=1) + locs = np.stack((x_locs, y_locs), axis=-1).astype(np.float32) + max_val_x = np.amax(simcc_x, axis=1) + max_val_y = np.amax(simcc_y, axis=1) + + # get maximum value across x and y axis + mask = max_val_x > max_val_y + max_val_x[mask] = max_val_y[mask] + vals = max_val_x + locs[vals <= 0.0] = -1 + + # reshape + locs = locs.reshape(N, K, 2) + vals = vals.reshape(N, K) + + return locs, vals + + +def decode(simcc_x: np.ndarray, simcc_y: np.ndarray, simcc_split_ratio) -> Tuple[np.ndarray, np.ndarray]: + """Modulate simcc distribution with Gaussian. + + Args: + simcc_x (np.ndarray[K, Wx]): model predicted simcc in x. + simcc_y (np.ndarray[K, Wy]): model predicted simcc in y. + simcc_split_ratio (int): The split ratio of simcc. + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: keypoints in shape (K, 2) or (n, K, 2) + - np.ndarray[float32]: scores in shape (K,) or (n, K) + """ + keypoints, scores = get_simcc_maximum(simcc_x, simcc_y) + keypoints /= simcc_split_ratio + + return keypoints, scores + + +def inference_pose(session, out_bbox, oriImg): + h, w = session.get_inputs()[0].shape[2:] + model_input_size = (w, h) + resized_img, center, scale = preprocess(oriImg, out_bbox, model_input_size) + outputs = inference(session, resized_img) + keypoints, scores = postprocess(outputs, model_input_size, center, scale) + + return keypoints, scores diff --git a/fashn_vton/dwpose/utils.py b/fashn_vton/dwpose/utils.py new file mode 100644 index 0000000..31f3b2b --- /dev/null +++ b/fashn_vton/dwpose/utils.py @@ -0,0 +1,227 @@ +""" +Drawing utilities adapted from DWPose (Apache-2.0): +https://github.com/IDEA-Research/DWPose +""" + +import math + +import cv2 +import matplotlib +import numpy as np + +eps = 0.01 + + +def draw_bodypose_gray(canvas, candidate, subset): + H, W = canvas.shape + candidate = np.array(candidate) + subset = np.array(subset) + + limbSeq = [ + [2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10], + [10, 11], [2, 12], [12, 13], [13, 14], [2, 1], [1, 15], [15, 17], + [1, 16], [16, 18], + ] + + base_values = np.linspace(20, 240, 18).astype(np.uint8) + stickwidth = 4 + limb_canvas = np.zeros_like(canvas) + + for i in range(17): + limb_gray = int(base_values[i]) + for n in range(len(subset)): + indexA = int(subset[n][limbSeq[i][0] - 1]) + indexB = int(subset[n][limbSeq[i][1] - 1]) + if indexA == -1 or indexB == -1: + continue + + xA = int(candidate[indexA][0] * W) + yA = int(candidate[indexA][1] * H) + xB = int(candidate[indexB][0] * W) + yB = int(candidate[indexB][1] * H) + + mX = (xA + xB) // 2 + mY = (yA + yB) // 2 + length = int(math.hypot(xA - xB, yA - yB)) + angle = int(math.degrees(math.atan2(yA - yB, xA - xB))) + + polygon = cv2.ellipse2Poly((mX, mY), (length // 2, stickwidth), angle, 0, 360, 1) + cv2.fillConvexPoly(limb_canvas, polygon, limb_gray) + + canvas = np.maximum(canvas, (limb_canvas * 0.6).astype(np.uint8)) + + keypoint_to_limb_map = {} + for i, (a, b) in enumerate(limbSeq): + if a not in keypoint_to_limb_map: + keypoint_to_limb_map[a] = [] + if b not in keypoint_to_limb_map: + keypoint_to_limb_map[b] = [] + keypoint_to_limb_map[a].append(i) + keypoint_to_limb_map[b].append(i) + + for i in range(1, 19): + if i not in keypoint_to_limb_map: + continue + + connected_limbs = keypoint_to_limb_map[i] + if connected_limbs: + point_gray = min(255, int(base_values[connected_limbs[0]] * 1.3)) + else: + point_gray = 200 + + for n in range(len(subset)): + index = int(subset[n][i - 1]) + if index == -1: + continue + + x = int(candidate[index][0] * W) + y = int(candidate[index][1] * H) + cv2.circle(canvas, (x, y), 4, point_gray, thickness=-1) + + return canvas + + +def draw_bodypose(canvas, candidate, subset): + H, W, C = canvas.shape + candidate = np.array(candidate) + subset = np.array(subset) + + stickwidth = 4 + + limbSeq = [ + [2, 3], [2, 6], [3, 4], [4, 5], [6, 7], [7, 8], [2, 9], [9, 10], + [10, 11], [2, 12], [12, 13], [13, 14], [2, 1], [1, 15], [15, 17], + [1, 16], [16, 18], [3, 17], [6, 18], + ] + + colors = [ + [255, 0, 0], [255, 85, 0], [255, 170, 0], [255, 255, 0], + [170, 255, 0], [85, 255, 0], [0, 255, 0], [0, 255, 85], + [0, 255, 170], [0, 255, 255], [0, 170, 255], [0, 85, 255], + [0, 0, 255], [85, 0, 255], [170, 0, 255], [255, 0, 255], + [255, 0, 170], [255, 0, 85], + ] + + for i in range(17): + for n in range(len(subset)): + index = subset[n][np.array(limbSeq[i]) - 1] + if -1 in index: + continue + Y = candidate[index.astype(int), 0] * float(W) + X = candidate[index.astype(int), 1] * float(H) + mX = np.mean(X) + mY = np.mean(Y) + length = ((X[0] - X[1]) ** 2 + (Y[0] - Y[1]) ** 2) ** 0.5 + angle = math.degrees(math.atan2(X[0] - X[1], Y[0] - Y[1])) + polygon = cv2.ellipse2Poly((int(mY), int(mX)), (int(length / 2), stickwidth), int(angle), 0, 360, 1) + cv2.fillConvexPoly(canvas, polygon, colors[i]) + + canvas = (canvas * 0.6).astype(np.uint8) + + for i in range(18): + for n in range(len(subset)): + index = int(subset[n][i]) + if index == -1: + continue + x, y = candidate[index][0:2] + x = int(x * W) + y = int(y * H) + cv2.circle(canvas, (int(x), int(y)), 4, colors[i], thickness=-1) + + return canvas + + +def draw_handpose(canvas, all_hand_peaks): + H, W, C = canvas.shape + + edges = [ + [0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], + [0, 9], [9, 10], [10, 11], [11, 12], [0, 13], [13, 14], [14, 15], + [15, 16], [0, 17], [17, 18], [18, 19], [19, 20], + ] + + for peaks in all_hand_peaks: + peaks = np.array(peaks) + + for ie, e in enumerate(edges): + x1, y1 = peaks[e[0]] + x2, y2 = peaks[e[1]] + x1 = int(x1 * W) + y1 = int(y1 * H) + x2 = int(x2 * W) + y2 = int(y2 * H) + if x1 > eps and y1 > eps and x2 > eps and y2 > eps: + cv2.line( + canvas, + (x1, y1), + (x2, y2), + matplotlib.colors.hsv_to_rgb([ie / float(len(edges)), 1.0, 1.0]) * 255, + thickness=2, + ) + + for i, keypoint in enumerate(peaks): + x, y = keypoint + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 4, (0, 0, 255), thickness=-1) + return canvas + + +def draw_facepose(canvas, all_lmks): + H, W, C = canvas.shape + for lmks in all_lmks: + lmks = np.array(lmks) + for lmk in lmks: + x, y = lmk + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 3, (255, 255, 255), thickness=-1) + return canvas + + +def draw_handpose_gray(canvas, all_hand_peaks): + H, W = canvas.shape + + edges = [ + [0, 1], [1, 2], [2, 3], [3, 4], [0, 5], [5, 6], [6, 7], [7, 8], + [0, 9], [9, 10], [10, 11], [11, 12], [0, 13], [13, 14], [14, 15], + [15, 16], [0, 17], [17, 18], [18, 19], [19, 20], + ] + + edge_values = np.linspace(160, 220, len(edges)).astype(np.uint8) + + for peaks in all_hand_peaks: + peaks = np.array(peaks) + + for ie, e in enumerate(edges): + x1, y1 = peaks[e[0]] + x2, y2 = peaks[e[1]] + x1 = int(x1 * W) + y1 = int(y1 * H) + x2 = int(x2 * W) + y2 = int(y2 * H) + if x1 > eps and y1 > eps and x2 > eps and y2 > eps: + cv2.line(canvas, (x1, y1), (x2, y2), int(edge_values[ie]), thickness=2) + + for i, keypoint in enumerate(peaks): + x, y = keypoint + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 4, 240, thickness=-1) + return canvas + + +def draw_facepose_gray(canvas, all_lmks): + H, W = canvas.shape + for lmks in all_lmks: + lmks = np.array(lmks) + for lmk in lmks: + x, y = lmk + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 3, 200, thickness=-1) + return canvas diff --git a/fashn_vton/dwpose/wholebody.py b/fashn_vton/dwpose/wholebody.py new file mode 100644 index 0000000..0331a53 --- /dev/null +++ b/fashn_vton/dwpose/wholebody.py @@ -0,0 +1,51 @@ +""" +Wholebody pose detection adapted from DWPose (Apache-2.0): +https://github.com/IDEA-Research/DWPose +""" + +import os + +import numpy as np +import onnxruntime as ort + +from .onnxdet import inference_detector +from .onnxpose import inference_pose + + +class Wholebody: + def __init__(self, checkpoints_dir, device="cuda:0"): + if device.startswith("cuda"): + device_id = int(device.split(":")[-1]) + provider_options = [{"device_id": str(device_id)}] + providers = ["CUDAExecutionProvider"] + else: + providers = ["CPUExecutionProvider"] + provider_options = None + onnx_det = os.path.join(checkpoints_dir, "yolox_l.onnx") + onnx_pose = os.path.join(checkpoints_dir, "dw-ll_ucoco_384.onnx") + + self.session_det = ort.InferenceSession( + path_or_bytes=onnx_det, providers=providers, provider_options=provider_options + ) + self.session_pose = ort.InferenceSession( + path_or_bytes=onnx_pose, providers=providers, provider_options=provider_options + ) + + def __call__(self, oriImg): + det_result = inference_detector(self.session_det, oriImg) + keypoints, scores = inference_pose(self.session_pose, det_result, oriImg) + + keypoints_info = np.concatenate((keypoints, scores[..., None]), axis=-1) + # compute neck joint + neck = np.mean(keypoints_info[:, [5, 6]], axis=1) + # neck score when visualizing pred + neck[:, 2:4] = np.logical_and(keypoints_info[:, 5, 2:4] > 0.3, keypoints_info[:, 6, 2:4] > 0.3).astype(int) + new_keypoints_info = np.insert(keypoints_info, 17, neck, axis=1) + mmpose_idx = [17, 6, 8, 10, 7, 9, 12, 14, 16, 13, 15, 2, 1, 4, 3] + openpose_idx = [1, 2, 3, 4, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17] + new_keypoints_info[:, openpose_idx] = new_keypoints_info[:, mmpose_idx] + keypoints_info = new_keypoints_info + + keypoints, scores = keypoints_info[..., :2], keypoints_info[..., 2] + + return keypoints, scores diff --git a/fashn_vton/pipeline.py b/fashn_vton/pipeline.py new file mode 100644 index 0000000..6a90ecb --- /dev/null +++ b/fashn_vton/pipeline.py @@ -0,0 +1,343 @@ +"""TryOn Pipeline.""" + +import logging +import os +from dataclasses import dataclass +from typing import List, Literal, Optional + +import cv2 +import numpy as np +import torch +from fashn_human_parser import CATEGORY_TO_BODY_COVERAGE, FashnHumanParser +from PIL import Image +from tqdm.auto import tqdm + +from .dwpose import DWposeDetector, draw_pose +from .preprocessing import ( + BODY_COVERAGE_TO_FASHN_LABELS, + FASHN_LABELS_TO_IDS, + AspectPreserveResize, + ResizePad, + create_clothing_agnostic_image, + create_garment_image, +) +from .tryon_mmdit import TryOnModel +from .utils import ( + get_dummy_dw_keypoints, + get_rf_schedule, + load_checkpoint, + normalize_uint8_to_neg1_1, + numpy_to_torch, + setup_logger, + tensor_to_pil, +) + + +@dataclass +class PipelineOutput: + """Pipeline output container.""" + + images: List[Image.Image] + + +class TryOnPipeline: + """ + TryOn inference pipeline. + + Args: + weights_dir: Directory containing model weights (model.safetensors, dwpose/) + device: Device to run on ('cuda', 'cpu', or None for auto-detect) + logger: Optional logger instance + + Example: + pipeline = TryOnPipeline(weights_dir="./weights") + result = pipeline(person_image, garment_image, category="tops") + """ + + CATEGORY_TO_LABEL = {"tops": 1, "bottoms": 2, "one-pieces": 3} + + def __init__( + self, + weights_dir: str, + device: Optional[str] = None, + logger: Optional[logging.Logger] = None, + ): + self.weights_dir = os.path.abspath(weights_dir) + self.logger = logger or setup_logger("TryOnPipeline", level=logging.INFO) + + # Setup device + self.device = torch.device(device if device else ("cuda" if torch.cuda.is_available() else "cpu")) + self.logger.info(f"Using device: {self.device}") + + # Setup inference dtype + self.inference_dtype = torch.float32 + if self.device.type == "cuda" and torch.cuda.is_bf16_supported(): + self.inference_dtype = torch.bfloat16 + self.logger.info(f"Using dtype: {self.inference_dtype}") + + # Validate weights exist + self._validate_weights() + + # Load models + self._setup_tryon_model() + self._setup_pose_model() + self._setup_hp_model() + + # Setup transforms (derived from model input shape) + h, w = self.tryon_model.input_shape + max_dim = max(h, w) + self.pre_resize = AspectPreserveResize(target_size=(max_dim, max_dim), mode="fit", backend="pil") + self.resize_pad_fn = ResizePad((w, h), backend="opencv") + + def _validate_weights(self): + """Check that required weight files exist.""" + tryon_path = os.path.join(self.weights_dir, "model.safetensors") + dwpose_dir = os.path.join(self.weights_dir, "dwpose") + yolox_path = os.path.join(dwpose_dir, "yolox_l.onnx") + dwpose_path = os.path.join(dwpose_dir, "dw-ll_ucoco_384.onnx") + + missing = [] + if not os.path.exists(tryon_path): + missing.append(tryon_path) + if not os.path.exists(yolox_path): + missing.append(yolox_path) + if not os.path.exists(dwpose_path): + missing.append(dwpose_path) + + if missing: + raise FileNotFoundError( + "Missing model weights:\n" + + "\n".join(f" - {p}" for p in missing) + + f"\n\nPlease run:\n python scripts/download_weights.py --weights-dir {self.weights_dir}" + ) + + def _setup_tryon_model(self): + """Load the TryOn model.""" + model_path = os.path.join(self.weights_dir, "model.safetensors") + self.logger.info(f"Loading TryOnModel from {model_path}") + + self.tryon_model = TryOnModel() + state_dict = load_checkpoint(model_path, device=str(self.device)) + self.tryon_model.load_state_dict(state_dict) + self.tryon_model.to(self.device, dtype=self.inference_dtype).eval() + + self.logger.info("TryOnModel loaded") + + def _setup_pose_model(self): + """Load DWPose model.""" + dwpose_dir = os.path.join(self.weights_dir, "dwpose") + self.logger.info(f"Loading DWPose from {dwpose_dir}") + + dwpose_device = f"cuda:{self.device.index or 0}" if self.device.type == "cuda" else "cpu" + self.pose_model = DWposeDetector(checkpoints_dir=dwpose_dir, device=dwpose_device) + + self.logger.info("DWPose loaded") + + def _setup_hp_model(self): + """Load human parsing model.""" + self.logger.info("Loading FashnHumanParser") + + hp_device = "cuda" if self.device.type == "cuda" else "cpu" + self.hp_model = FashnHumanParser(device=hp_device) + + self.logger.info("FashnHumanParser loaded") + + @torch.inference_mode() + def _sample( + self, + *, + ca_images: torch.Tensor, + garment_images: torch.Tensor, + person_poses: torch.Tensor, + garment_poses: torch.Tensor, + garment_categories: torch.Tensor, + num_timesteps: int = 30, + time_shift_mu: float = 1.5, + guidance_scale: float = 1.5, + skip_cfg_last_n_steps: int = 1, + use_tqdm: bool = True, + callback: Optional[callable] = None, + ) -> List[Image.Image]: + """Euler sampling with CFG.""" + device, dtype = ca_images.device, ca_images.dtype + batch_size = ca_images.shape[0] + + # Init noisy images + c, h, w = self.tryon_model.channels_in, *self.tryon_model.input_shape + images = torch.randn((batch_size, c, h, w), dtype=dtype, device=device) + + # Time schedule (from 0 -> 1) + timesteps = get_rf_schedule(num_steps=num_timesteps, mu=time_shift_mu) + + model_kwargs = { + "person_poses": person_poses, + "garment_poses": garment_poses, + "ca_images": ca_images, + "garment_images": garment_images, + "garment_categories": garment_categories, + } + + # Euler sampling loop + total_steps = len(timesteps) - 1 + for step_idx, (t_curr, t_prev) in enumerate( + tqdm( + zip(timesteps[:-1], timesteps[1:]), + desc="Sampling", + total=total_steps, + disable=not use_tqdm, + ) + ): + if callback: + callback(step_idx, total_steps) + dt = t_prev - t_curr + t_vec = torch.full((batch_size,), t_curr, dtype=dtype, device=device) + + pred = self.tryon_model.forward_for_cfg(images, t_vec, **model_kwargs) + v_c, v_u = pred["v_c"], pred["v_u"] + + # Skip CFG at final steps to prevent color saturation + if skip_cfg_last_n_steps > 0 and step_idx >= num_timesteps - skip_cfg_last_n_steps: + v_guided = v_c + else: + v_guided = v_u + guidance_scale * (v_c - v_u) + + images = images + dt * v_guided + + images = images.to(dtype=torch.float).clamp_(-1.0, 1.0) + return [tensor_to_pil(img, unnormalize=True) for img in images] + + @torch.inference_mode() + def __call__( + self, + person_image: Image.Image, + garment_image: Image.Image, + category: Literal["tops", "bottoms", "one-pieces"], + garment_photo_type: Literal["model", "flat-lay"] = "model", + num_samples: int = 1, + num_timesteps: int = 30, + guidance_scale: float = 1.5, + skip_cfg_last_n_steps: int = 1, + seed: int = 42, + segmentation_free: bool = True, + callback: Optional[callable] = None, + ) -> PipelineOutput: + """ + Run virtual try-on inference. + + Args: + person_image: RGB image of the person to dress. + garment_image: RGB image of the garment (model photo or flat-lay). + category: Garment category - "tops", "bottoms", or "one-pieces". + garment_photo_type: "model" if garment is worn by a person, + "flat-lay" for product shots on plain backgrounds. + num_samples: Number of output images to generate (1-4). + num_timesteps: Diffusion sampling steps. Higher = better quality, slower. + Recommended: 20 (fast), 30 (balanced), 50 (quality). + guidance_scale: Classifier-free guidance strength. + skip_cfg_last_n_steps: Skip CFG for final N steps to prevent color saturation. + seed: Random seed for reproducibility. + segmentation_free: If True, generate without masking the person image. + Recommended for better body preservation and unconstrained garment volume + (allows garments to expand beyond the original outfit's boundaries). + + Returns: + PipelineOutput with `images` list containing generated PIL Images. + """ + # Set seed + torch.manual_seed(seed) + if self.device.type == "cuda": + torch.cuda.manual_seed_all(seed) + np.random.seed(seed) + + # Pre-resize for pose detection quality + person_image = self.pre_resize(person_image, allow_upsampling=False) + garment_image = self.pre_resize(garment_image, allow_upsampling=False) + + person_image_np = np.array(person_image) + garment_image_np = np.array(garment_image) + + # Pose detection (DWPose expects BGR) + person_pose = self.pose_model(person_image_np[..., ::-1]) + garment_pose = ( + get_dummy_dw_keypoints() + if garment_photo_type == "flat-lay" + else self.pose_model(garment_image_np[..., ::-1]) + ) + + person_pose_img = draw_pose(person_pose, person_image_np.shape[0], person_image_np.shape[1], grayscale=True) + garment_pose_img = draw_pose(garment_pose, garment_image_np.shape[0], garment_image_np.shape[1], grayscale=True) + + # Human parsing + person_seg_pred = self.hp_model.predict(person_image_np) + garment_seg_pred = self.hp_model.predict(garment_image_np) + + # Get labels to segment based on category + body_coverage = CATEGORY_TO_BODY_COVERAGE.get(category) + labels_to_segment = BODY_COVERAGE_TO_FASHN_LABELS.get(body_coverage) + labels_to_segment_indices = [FASHN_LABELS_TO_IDS[label] for label in labels_to_segment] + + # Create clothing-agnostic and garment images + ca_image = create_clothing_agnostic_image( + img_np=person_image_np.copy(), + seg_pred=person_seg_pred.copy(), + labels_to_segment_indices=labels_to_segment_indices.copy(), + body_coverage=body_coverage, + disable_masking=segmentation_free, + logger=self.logger, + ) + + garment_image_processed = create_garment_image( + img_np=garment_image_np, + seg_pred=garment_seg_pred, + labels_to_segment_indices=labels_to_segment_indices.copy(), + disable_masking=garment_photo_type == "flat-lay", + ) + + # Resize/pad for model input + ca_image = self.resize_pad_fn(ca_image, mem_padding=True) + garment_image_processed = self.resize_pad_fn(garment_image_processed) + person_pose_img = self.resize_pad_fn(person_pose_img, interpolation=cv2.INTER_NEAREST_EXACT) + garment_pose_img = self.resize_pad_fn(garment_pose_img, interpolation=cv2.INTER_NEAREST_EXACT) + + # Prepare tensors + def prepare_tensor(img: np.ndarray) -> torch.Tensor: + t = numpy_to_torch(img).unsqueeze(0) + t = normalize_uint8_to_neg1_1(t) + t = t.to(self.device).repeat(num_samples, 1, 1, 1) + return t + + ca_tensor = prepare_tensor(ca_image) + garment_tensor = prepare_tensor(garment_image_processed) + person_pose_tensor = prepare_tensor(person_pose_img) + garment_pose_tensor = prepare_tensor(garment_pose_img) + + garment_categories = ( + torch.tensor(self.CATEGORY_TO_LABEL[category]).unsqueeze(0).repeat(num_samples).to(self.device) + ) + + # Cast to inference dtype + ca_tensor = ca_tensor.to(dtype=self.inference_dtype) + garment_tensor = garment_tensor.to(dtype=self.inference_dtype) + person_pose_tensor = person_pose_tensor.to(dtype=self.inference_dtype) + garment_pose_tensor = garment_pose_tensor.to(dtype=self.inference_dtype) + + # Run sampling + self.logger.info(f"Running inference with {num_timesteps} timesteps...") + images = self._sample( + ca_images=ca_tensor, + garment_images=garment_tensor, + person_poses=person_pose_tensor, + garment_poses=garment_pose_tensor, + garment_categories=garment_categories, + num_timesteps=num_timesteps, + guidance_scale=guidance_scale, + skip_cfg_last_n_steps=skip_cfg_last_n_steps, + callback=callback, + ) + + # Unpad outputs + images = [self.resize_pad_fn.unpad(img) for img in images] + + self.logger.info(f"Generated {len(images)} images") + + return PipelineOutput(images=images) diff --git a/fashn_vton/preprocessing/__init__.py b/fashn_vton/preprocessing/__init__.py new file mode 100644 index 0000000..635d4ec --- /dev/null +++ b/fashn_vton/preprocessing/__init__.py @@ -0,0 +1,22 @@ +"""Preprocessing utilities.""" + +from .agnostic import ( + BODY_COVERAGE_TO_FASHN_LABELS, + FASHN_LABELS_TO_IDS, + create_clothing_agnostic_image, + create_garment_image, +) +from .transforms import AspectPreserveResize, PadToShape, ResizePad + +__all__ = [ + # Clothing-agnostic creation + "create_clothing_agnostic_image", + "create_garment_image", + # Constants + "FASHN_LABELS_TO_IDS", + "BODY_COVERAGE_TO_FASHN_LABELS", + # Transforms + "AspectPreserveResize", + "ResizePad", + "PadToShape", +] diff --git a/fashn_vton/preprocessing/__pycache__/__init__.cpython-312.pyc b/fashn_vton/preprocessing/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1dd77949af80cb2fd87aeea85dcfd31da1e92a7e GIT binary patch literal 564 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 W-tXL`ptxN3Mbo3EAF)U-^#1?^#>piB literal 0 HcmV?d00001 diff --git a/fashn_vton/preprocessing/__pycache__/transforms.cpython-312.pyc b/fashn_vton/preprocessing/__pycache__/transforms.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..0fbd6299d541cacd1739ae95c2fc725035d6e184 GIT binary patch literal 11237 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 literal 0 HcmV?d00001 diff --git a/fashn_vton/preprocessing/agnostic.py b/fashn_vton/preprocessing/agnostic.py new file mode 100644 index 0000000..09f864f --- /dev/null +++ b/fashn_vton/preprocessing/agnostic.py @@ -0,0 +1,212 @@ +"""Clothing-agnostic image creation.""" + +import logging +from typing import List, Optional + +import numpy as np +from fashn_human_parser import BODY_COVERAGE_TO_LABELS, IDENTITY_LABELS, LABELS_TO_IDS + +from ..utils import setup_logger +from .masks import asymmetric_dilate_mask, create_bounded_mask, create_contour_following_mask, dilate_mask + +# Re-export constants from fashn_human_parser for convenience +FASHN_LABELS_TO_IDS = LABELS_TO_IDS +BODY_COVERAGE_TO_FASHN_LABELS = BODY_COVERAGE_TO_LABELS +IDENTITY_FASHN_LABELS = tuple(IDENTITY_LABELS) + + +def _default(val, default_val): + """Return val if not None, else default_val (or call it if callable).""" + if val is not None: + return val + return default_val() if callable(default_val) else default_val + + +def _create_hybrid_contour_bounded_mask( + contour_mask: np.ndarray, + bounded_mask: np.ndarray, + min_distance_threshold: float = 100.0, + logger: Optional[logging.Logger] = None, + baseline_height: float = 864.0, +) -> np.ndarray: + """ + Create hybrid mask by removing over-aggressive bounded expansions. + + Combines contour-following and bounding-box masks, removing pixels from + the bounded mask that are too far from the contour mask. + + Args: + contour_mask: Precise contour-following mask + bounded_mask: More aggressive bounded box mask + min_distance_threshold: Max distance from contour for bounded pixels (at baseline height) + logger: Optional logger instance + baseline_height: Reference height for scaling threshold + + Returns: + Hybrid mask with over-aggressive bounded pixels removed + """ + import cv2 + + logger = _default(logger, lambda: setup_logger("hybrid_mask")) + + # Scale threshold based on image height + height_scale = contour_mask.shape[0] / baseline_height + scaled_threshold = min_distance_threshold * height_scale + + if scaled_threshold <= 0: + logger.debug("scaled_threshold<=0, returning pure contour mask") + return contour_mask + + hybrid_mask = bounded_mask.copy() + + # Find pixels in bounded but not in contour (potential over-expansion) + bounded_extra = bounded_mask & ~contour_mask + + if not np.any(bounded_extra): + logger.debug("No extra pixels in bounded mask, returning bounded mask") + return bounded_mask + + # Compute distance from bounded extra pixels to nearest contour mask pixel + distance_from_contour = cv2.distanceTransform( + (~contour_mask).astype(np.uint8), cv2.DIST_L2, 5 + ) + + # Remove pixels too far from contour + bounded_extra_coords = np.where(bounded_extra) + extra_distances = distance_from_contour[bounded_extra] + remove_mask = extra_distances > scaled_threshold + remove_coords = (bounded_extra_coords[0][remove_mask], bounded_extra_coords[1][remove_mask]) + + hybrid_mask[remove_coords] = False + + return hybrid_mask + + +def create_garment_image( + img_np: np.ndarray, + seg_pred: np.ndarray, + labels_to_segment_indices: List[int], + mask_value: int = 127, + disable_masking: bool = False, +) -> np.ndarray: + """ + Create garment image with optional masking. + + Masks out regions not belonging to the specified garment labels. + + Args: + img_np: Input image array (will be modified in-place) + seg_pred: Segmentation prediction array + labels_to_segment_indices: List of label indices to keep + mask_value: Value to fill masked regions (default: 127 gray) + disable_masking: If True, return image unchanged + + Returns: + Processed garment image array + """ + if not disable_masking: + selected_labels_mask = np.isin(seg_pred, labels_to_segment_indices) + img_np[~selected_labels_mask] = mask_value + + return img_np + + +def create_clothing_agnostic_image( + img_np: np.ndarray, + seg_pred: np.ndarray, + labels_to_segment_indices: List[int], + body_coverage: str, + mask_value: int = 127, + disable_masking: bool = False, + min_distance_threshold: float = 100.0, + baseline_height: float = 864.0, + mask_limbs: bool = True, + logger: Optional[logging.Logger] = None, +) -> np.ndarray: + """ + Create clothing-agnostic image. + + Masks garments and body parts based on the target category. + + Args: + img_np: Input image array (will be modified in-place) + seg_pred: Segmentation prediction array + labels_to_segment_indices: List of label indices to mask + body_coverage: Coverage type ("full", "upper", or "lower") + mask_value: Value to fill masked regions (default: 127 gray) + disable_masking: If True, return image unchanged + min_distance_threshold: Distance threshold for hybrid mask (at baseline height) + baseline_height: Reference height for parameter scaling + mask_limbs: If True, also mask arms/legs based on body_coverage + logger: Optional logger instance + + Returns: + Clothing-agnostic image array + """ + logger = _default(logger, lambda: setup_logger("clothing_agnostic")) + + if disable_masking: + return img_np + + # Scale parameters based on image height + height_scale = seg_pred.shape[0] / baseline_height + logger.debug(f"Height scale factor: {height_scale:.3f} (height: {seg_pred.shape[0]})") + + # Add body parts to mask based on body coverage + labels_ids_dict = FASHN_LABELS_TO_IDS.copy() + if mask_limbs: + if body_coverage in ("full", "upper"): + labels_to_segment_indices += [labels_ids_dict["arms"], labels_ids_dict["torso"]] + if body_coverage in ("full", "lower"): + labels_to_segment_indices += [labels_ids_dict["legs"]] + + # Create base mask + mask = np.isin(seg_pred, labels_to_segment_indices) + + # Buffer mask to avoid leaks + scaled_buffer_kernel = max(1, int(4 * height_scale)) + buffer_mask = dilate_mask(mask, kernel=(scaled_buffer_kernel, scaled_buffer_kernel)) + + # Create bounded mask + bounded_mask = create_bounded_mask(mask) + + # Create contour following mask + scaled_brush_radius = max(1, int(18 * height_scale)) + contour_mask = create_contour_following_mask(mask, brush_radius=scaled_brush_radius) + + # Create hybrid mask + ca_mask = _create_hybrid_contour_bounded_mask( + contour_mask, bounded_mask, logger=logger, min_distance_threshold=min_distance_threshold + ) + + # Apply asymmetric dilation for inpainting workspace + scaled_right = int(33 * height_scale) + scaled_left = int(33 * height_scale) + scaled_up = int(16 * height_scale) + scaled_down = int(16 * height_scale) + ca_mask = asymmetric_dilate_mask(ca_mask, right=scaled_right, left=scaled_left, up=scaled_up, down=scaled_down) + + # Create exclusion mask (regions to preserve) + identity_ids = [labels_ids_dict[label] for label in IDENTITY_FASHN_LABELS] + + # Conditional identity based on coverage + if body_coverage == "upper": + identity_ids.append(labels_ids_dict["legs"]) + elif body_coverage == "lower": + identity_ids.append(labels_ids_dict["arms"]) + + exclusion_mask = np.isin(seg_pred, identity_ids) + + # Handle hands and feet + if body_coverage in ("full", "upper"): + hands_mask = seg_pred == labels_ids_dict["hands"] + exclusion_mask = exclusion_mask | hands_mask + + if body_coverage in ("full", "lower"): + feet_mask = seg_pred == labels_ids_dict["feet"] + exclusion_mask = exclusion_mask | feet_mask + + final_mask = buffer_mask | (ca_mask & ~exclusion_mask) + img_np[final_mask] = mask_value + + return img_np diff --git a/fashn_vton/preprocessing/masks.py b/fashn_vton/preprocessing/masks.py new file mode 100644 index 0000000..43c4f6b --- /dev/null +++ b/fashn_vton/preprocessing/masks.py @@ -0,0 +1,163 @@ +"""Mask processing utilities.""" + +import cv2 +import numpy as np + + +def dilate_mask(mask: np.ndarray, kernel: tuple = (33, 33), iterations: int = 1) -> np.ndarray: + """ + Dilate the mask to create a buffer zone around the selected areas. + + Args: + mask: Input binary mask + kernel: Dilation kernel size + iterations: Number of dilation iterations + + Returns: + Dilated boolean mask + """ + kernel = np.ones(kernel, np.uint8) + dilated_mask = cv2.dilate(mask.astype(np.uint8), kernel, iterations=iterations) + return dilated_mask.astype(bool) + + +def create_bounded_mask(mask: np.ndarray) -> np.ndarray: + """ + Create a mask that fills the bounding box of the input mask. + + Args: + mask: Input binary mask + + Returns: + Bounded mask filling the bounding rectangle + """ + bounded_mask = np.zeros_like(mask) + x, y, w, h = cv2.boundingRect(mask.astype(np.uint8)) + bounded_mask[y : y + h, x : x + w] = 1 + return bounded_mask + + +def asymmetric_dilate_mask( + mask: np.ndarray, right: int, left: int, up: int, down: int +) -> np.ndarray: + """ + Dilate mask asymmetrically in different directions. + + Args: + mask: Input binary mask + right: Dilation amount to the right + left: Dilation amount to the left + up: Dilation amount upward + down: Dilation amount downward + + Returns: + Asymmetrically dilated boolean mask + """ + if mask.dtype == bool: + mask = mask.astype(np.uint8) * 255 + + kernel_width = left + right + 1 + kernel_height = up + down + 1 + kernel = np.ones((kernel_height, kernel_width), np.uint8) + + anchor_x = right + anchor_y = down + + mask = cv2.dilate(mask, kernel, anchor=(anchor_x, anchor_y)) + + return mask.astype(bool) + + +def create_contour_following_mask( + mask: np.ndarray, + brush_radius: int = 36, + smoothing_sigma: float | None = None, + supersample: int = 1, + keep_holes: bool = False, +) -> np.ndarray: + """ + Inflate mask so it looks like it was painted with a large soft brush. + + Uses signed distance field for smooth contour following with optional + supersampling for ultra-clean edges. + + Args: + mask: Input segmentation (foreground != 0) + brush_radius: Extra pixels the virtual brush extends beyond the garment + smoothing_sigma: Edge-smoothing sigma (Gaussian blur). If None, defaults to brush_radius / 2.5 + supersample: 1 = fastest; 2-4 for ultra-clean edges via upscale + max-pool downsample + keep_holes: If True, preserve interior holes (e.g. neck opening) + + Returns: + Boolean mask, same H×W as input, guaranteed to contain the original mask + """ + if mask.dtype != np.bool_: + mask = mask.astype(bool) + + if smoothing_sigma is None: + smoothing_sigma = brush_radius / 2.5 + + if supersample < 1 or not isinstance(supersample, int): + raise ValueError("`supersample` must be a positive integer.") + + # Optional super-sampling + if supersample > 1: + mask_work = cv2.resize( + mask.astype(np.uint8), + dsize=None, + fx=supersample, + fy=supersample, + interpolation=cv2.INTER_NEAREST, + ).astype(bool) + br = brush_radius * supersample + sig = smoothing_sigma * supersample + else: + mask_work = mask.copy() + br = brush_radius + sig = smoothing_sigma + + # Dilation ensures superset + se = cv2.getStructuringElement(cv2.MORPH_ELLIPSE, (2 * br + 1, 2 * br + 1)) + dilated = cv2.dilate(mask_work.astype(np.uint8), se).astype(bool) + + # Signed distance field + dist_out = cv2.distanceTransform((~dilated).astype(np.uint8), cv2.DIST_L2, 5) + dist_in = cv2.distanceTransform(dilated.astype(np.uint8), cv2.DIST_L2, 5) + signed = dist_out - dist_in # <0 inside, >0 outside + + # Smooth the level set + signed_blur = cv2.GaussianBlur(signed, (0, 0), sig, borderType=cv2.BORDER_REPLICATE) + smooth = signed_blur <= 0 # level-set 0 + + # Optional hole fill + if not keep_holes: + smooth = _fill_holes_cv(smooth) + + # Containment: ensure original mask is included + smooth |= mask_work + + # Downsample if supersampled + if supersample > 1: + smooth = _max_pool_downsample(smooth, supersample) + + return smooth.astype(bool) + + +def _max_pool_downsample(arr: np.ndarray, factor: int) -> np.ndarray: + """Block-wise max-pooling downsample (factor must divide both axes).""" + h, w = arr.shape + if h % factor or w % factor: + raise ValueError("Supersample factor must divide mask dimensions.") + arr = arr.reshape(h // factor, factor, w // factor, factor) + return arr.any(axis=(1, 3)) + + +def _fill_holes_cv(binary: np.ndarray) -> np.ndarray: + """Fill interior holes of a binary mask using flood-fill.""" + h, w = binary.shape + flood_mask = np.zeros((h + 2, w + 2), np.uint8) + inv = (~binary).astype(np.uint8) # holes & background = 1 + flood = inv.copy() + cv2.floodFill(flood, flood_mask, (0, 0), 0) # erase the true background + holes = flood == 1 + return binary | holes diff --git a/fashn_vton/preprocessing/transforms.py b/fashn_vton/preprocessing/transforms.py new file mode 100644 index 0000000..b03aae3 --- /dev/null +++ b/fashn_vton/preprocessing/transforms.py @@ -0,0 +1,222 @@ +"""Image transforms for preprocessing.""" + +from typing import Literal, Optional, Tuple, Union + +import cv2 +import numpy as np +from PIL import Image, ImageOps + + +def _default(val, default_val): + """Return val if not None, else default_val (or call it if callable).""" + if val is not None: + return val + return default_val() if callable(default_val) else default_val + + +class AspectPreserveResize: + """ + Resize images while preserving aspect ratio. + + Args: + target_size: Target (width, height) + mode: Resize mode + - "fit": Scale to fit within target (may be smaller) + - "exceed": Scale to exceed target (may be larger) + - "short": Scale based on shorter dimension + - "long": Scale based on longer dimension + backend: "pil" or "opencv" + """ + + def __init__( + self, + target_size: Tuple[int, int], + mode: Literal["short", "long", "fit", "exceed"] = "fit", + backend: Literal["pil", "opencv"] = "pil", + ): + self.target_size = target_size + self.mode = mode + self.backend = backend + + def _get_or_infer_scale_factor( + self, width: int, height: int, allow_upsampling: bool = True + ) -> float: + target_width, target_height = self.target_size + scale_factor_width = target_width / width + scale_factor_height = target_height / height + + if self.mode == "long": + scale_factor = min(scale_factor_width, scale_factor_height) + elif self.mode == "short": + scale_factor = max(scale_factor_width, scale_factor_height) + elif self.mode == "fit": + scale_factor = min(scale_factor_width, scale_factor_height) + elif self.mode == "exceed": + scale_factor = max(scale_factor_width, scale_factor_height) + else: + raise ValueError("Invalid mode. It should be 'short', 'long', 'fit', or 'exceed'.") + + if not allow_upsampling and scale_factor > 1.0: + return 1.0 + + return scale_factor + + def _resize_image_pil( + self, img: Image.Image, scale_factor: float, interpolation: Optional[int] = None + ) -> Image.Image: + if scale_factor == 1.0: + return img + + width, height = img.size + new_width = int(scale_factor * width) + new_height = int(scale_factor * height) + + interpolation = _default(interpolation, Image.LANCZOS) + return img.resize((new_width, new_height), interpolation) + + def _resize_image_opencv( + self, img: np.ndarray, scale_factor: float, interpolation: Optional[int] = None + ) -> np.ndarray: + if scale_factor == 1.0: + return img + + height, width = img.shape[:2] + new_width = int(scale_factor * width) + new_height = int(scale_factor * height) + + interpolation = _default( + interpolation, cv2.INTER_LANCZOS4 if scale_factor > 1 else cv2.INTER_AREA + ) + + return cv2.resize(img, (new_width, new_height), interpolation=interpolation) + + def __call__( + self, + img: Union[Image.Image, np.ndarray], + allow_upsampling: bool = True, + interpolation: Optional[int] = None, + ) -> Union[Image.Image, np.ndarray]: + if self.backend == "pil": + width, height = img.size + elif self.backend == "opencv": + height, width = img.shape[:2] + + scale_factor = self._get_or_infer_scale_factor(width, height, allow_upsampling) + + if self.backend == "pil": + return self._resize_image_pil(img, scale_factor, interpolation=interpolation) + elif self.backend == "opencv": + return self._resize_image_opencv(img, scale_factor, interpolation=interpolation) + + +class PadToShape: + """ + Pad images to a target shape with symmetric padding. + + Args: + target_size: Target (width, height) + fill_value: Padding color (int for grayscale, tuple for RGB) + backend: "pil" or "opencv" + """ + + def __init__( + self, + target_size: Tuple[int, int], + fill_value: Union[int, tuple] = 0, + backend: Literal["pil", "opencv"] = "opencv", + ) -> None: + self.target_width, self.target_height = target_size + self.backend = backend + if isinstance(fill_value, int): + self.fill_value = (fill_value,) * 3 + else: + self.fill_value = fill_value + self.padding_mem: Optional[Tuple[int, int, int, int]] = None + + @staticmethod + def _calculate_needed_padding( + width: int, height: int, target_width: int, target_height: int + ) -> Tuple[int, int, int, int]: + total_width_padding = max(target_width - width, 0) + total_height_padding = max(target_height - height, 0) + + pad_left = total_width_padding // 2 + pad_top = total_height_padding // 2 + pad_right = total_width_padding - pad_left + pad_bottom = total_height_padding - pad_top + + return pad_left, pad_top, pad_right, pad_bottom + + def _pad_image_pil(self, img: Image.Image, padding: Tuple[int, int, int, int]) -> Image.Image: + return ImageOps.expand(img, border=padding, fill=self.fill_value) + + def _pad_image_opencv(self, img: np.ndarray, padding: Tuple[int, int, int, int]) -> np.ndarray: + pad_left, pad_top, pad_right, pad_bottom = padding + return cv2.copyMakeBorder( + img, pad_top, pad_bottom, pad_left, pad_right, cv2.BORDER_CONSTANT, value=self.fill_value + ) + + def unpad(self, img: Union[Image.Image, np.ndarray]) -> Union[Image.Image, np.ndarray]: + """Remove padding using stored padding dimensions.""" + if self.padding_mem is None: + raise ValueError("Padding memory is not set.") + + pad_left, pad_top, pad_right, pad_bottom = self.padding_mem + + if isinstance(img, Image.Image): + return img.crop((pad_left, pad_top, img.width - pad_right, img.height - pad_bottom)) + return img[pad_top : img.shape[0] - pad_bottom, pad_left : img.shape[1] - pad_right] + + def __call__( + self, + img: Union[Image.Image, np.ndarray], + mem_padding: bool = False, + ) -> Union[Image.Image, np.ndarray]: + if self.backend == "pil": + width, height = img.size + else: + height, width = img.shape[:2] + + padding = self._calculate_needed_padding(width, height, self.target_width, self.target_height) + + if mem_padding: + self.padding_mem = padding + + if self.backend == "pil": + return self._pad_image_pil(img, padding) + return self._pad_image_opencv(img, padding) + + +class ResizePad: + """ + Aspect-preserving resize followed by symmetric padding. + + Combines AspectPreserveResize and PadToShape to resize images to fit + within target dimensions while preserving aspect ratio, then pads + to reach exact target size. + + Args: + target_image_size: Target (width, height) + backend: "pil" or "opencv" + """ + + def __init__( + self, + target_image_size: Tuple[int, int], + backend: Literal["pil", "opencv"] = "opencv", + ) -> None: + self.resize_fn = AspectPreserveResize(target_size=target_image_size, mode="fit", backend=backend) + self.pad_fn = PadToShape(target_image_size, backend=backend) + + def unpad(self, img: Union[Image.Image, np.ndarray]) -> Union[Image.Image, np.ndarray]: + """Remove padding to restore original dimensions.""" + return self.pad_fn.unpad(img) + + def __call__( + self, + img: Union[Image.Image, np.ndarray], + mem_padding: bool = False, + interpolation: Optional[int] = None, + ) -> Union[Image.Image, np.ndarray]: + img = self.resize_fn(img, interpolation=interpolation) + return self.pad_fn(img, mem_padding=mem_padding) diff --git a/fashn_vton/tryon_mmdit.py b/fashn_vton/tryon_mmdit.py new file mode 100644 index 0000000..fab0ac3 --- /dev/null +++ b/fashn_vton/tryon_mmdit.py @@ -0,0 +1,563 @@ +""" +TryOn Model. + +Contains components adapted from FLUX.1 by Black Forest Labs (Apache-2.0): +https://github.com/black-forest-labs/flux +""" + +import math +from dataclasses import dataclass +from typing import Optional, Tuple + +import torch +from einops import rearrange, repeat +from torch import Tensor, nn + +from .utils import cast_tuple, compact, exists, unpack_images + + +# Use PyTorch's native scaled dot product attention (SDPA) +def _attn_processor(q: Tensor, k: Tensor, v: Tensor) -> Tensor: + """Scaled dot product attention using PyTorch native implementation.""" + return torch.nn.functional.scaled_dot_product_attention(q, k, v) + + +def attention(q: Tensor, k: Tensor, v: Tensor, pe: Tensor) -> Tensor: + q, k = apply_rope(q, k, pe) + x = _attn_processor(q, k, v) + x = rearrange(x, "B H L D -> B L (H D)") + + return x + + +def rope(pos: Tensor, dim: int, theta: int) -> Tensor: + assert dim % 2 == 0 + scale = torch.arange(0, dim, 2, dtype=torch.float64, device=pos.device) / dim + omega = 1.0 / (theta**scale) + out = torch.einsum("...n,d->...nd", pos, omega) + out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1) + out = rearrange(out, "b n d (i j) -> b n d i j", i=2, j=2) + return out.float() + + +def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]: + xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2) + xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2) + xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1] + xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1] + return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk) + + +class EmbedND(nn.Module): + def __init__(self, dim: int, theta: int, axes_dim: list[int]): + super().__init__() + self.dim = dim + self.theta = theta + self.axes_dim = axes_dim + + def forward(self, ids: Tensor) -> Tensor: + n_axes = ids.shape[-1] + emb = torch.cat( + [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(n_axes)], + dim=-3, + ) + + return emb.unsqueeze(1) + +class RMSNorm(torch.nn.Module): + def __init__(self, dim: int): + super().__init__() + self.scale = nn.Parameter(torch.ones(dim)) + + def forward(self, x: Tensor): + x_dtype = x.dtype + x = x.float() + rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6) + return (x * rrms).to(dtype=x_dtype) * self.scale + + +class QKNorm(torch.nn.Module): + def __init__(self, dim: int): + super().__init__() + self.query_norm = RMSNorm(dim) + self.key_norm = RMSNorm(dim) + + def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]: + q = self.query_norm(q) + k = self.key_norm(k) + return q.to(v), k.to(v) + + +class SelfAttention(nn.Module): + def __init__(self, dim: int, num_heads: int = 8, qkv_bias: bool = False): + super().__init__() + self.num_heads = num_heads + head_dim = dim // num_heads + + self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) + self.norm = QKNorm(head_dim) + self.proj = nn.Linear(dim, dim) + + def forward(self, x: Tensor, pe: Tensor) -> Tensor: + qkv = self.qkv(x) + q, k, v = rearrange(qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + q, k = self.norm(q, k, v) + x = attention(q, k, v, pe=pe) + x = self.proj(x) + return x + + +@dataclass +class ModulationOut: + shift: Tensor + scale: Tensor + gate: Tensor + + +class Modulation(nn.Module): + def __init__(self, dim: int, double: bool): + super().__init__() + self.is_double = double + self.multiplier = 6 if double else 3 + self.lin = nn.Linear(dim, self.multiplier * dim, bias=True) + + def forward(self, vec: Tensor) -> tuple[ModulationOut, ModulationOut | None]: + out = self.lin(nn.functional.silu(vec))[:, None, :].chunk(self.multiplier, dim=-1) + + return ( + ModulationOut(*out[:3]), + ModulationOut(*out[3:]) if self.is_double else None, + ) + + +class DoubleStreamBlock(nn.Module): + def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float, qkv_bias: bool = False): + super().__init__() + + mlp_hidden_dim = int(hidden_size * mlp_ratio) + self.num_heads = num_heads + self.hidden_size = hidden_size + self.img_mod = Modulation(hidden_size, double=True) + self.img_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.img_attn = SelfAttention(dim=hidden_size, num_heads=num_heads, qkv_bias=qkv_bias) + + self.img_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.img_mlp = nn.Sequential( + nn.Linear(hidden_size, mlp_hidden_dim, bias=True), + nn.GELU(approximate="tanh"), + nn.Linear(mlp_hidden_dim, hidden_size, bias=True), + ) + + self.txt_mod = Modulation(hidden_size, double=True) + self.txt_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.txt_attn = SelfAttention(dim=hidden_size, num_heads=num_heads, qkv_bias=qkv_bias) + + self.txt_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.txt_mlp = nn.Sequential( + nn.Linear(hidden_size, mlp_hidden_dim, bias=True), + nn.GELU(approximate="tanh"), + nn.Linear(mlp_hidden_dim, hidden_size, bias=True), + ) + + def forward(self, img: Tensor, txt: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]: + img_mod1, img_mod2 = self.img_mod(vec) + txt_mod1, txt_mod2 = self.txt_mod(vec) + + # prepare image for attention + img_modulated = self.img_norm1(img) + img_modulated = (1 + img_mod1.scale) * img_modulated + img_mod1.shift + img_qkv = self.img_attn.qkv(img_modulated) + img_q, img_k, img_v = rearrange(img_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + img_q, img_k = self.img_attn.norm(img_q, img_k, img_v) + + # prepare txt for attention + txt_modulated = self.txt_norm1(txt) + txt_modulated = (1 + txt_mod1.scale) * txt_modulated + txt_mod1.shift + txt_qkv = self.txt_attn.qkv(txt_modulated) + txt_q, txt_k, txt_v = rearrange(txt_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + txt_q, txt_k = self.txt_attn.norm(txt_q, txt_k, txt_v) + + # run actual attention + q = torch.cat((txt_q, img_q), dim=2) + k = torch.cat((txt_k, img_k), dim=2) + v = torch.cat((txt_v, img_v), dim=2) + + attn = attention(q, k, v, pe=pe) + txt_attn, img_attn = attn[:, : txt.shape[1]], attn[:, txt.shape[1] :] + + # calculate the img blocks + img = img + img_mod1.gate * self.img_attn.proj(img_attn) + img = img + img_mod2.gate * self.img_mlp((1 + img_mod2.scale) * self.img_norm2(img) + img_mod2.shift) + + # calculate the txt blocks + txt = txt + txt_mod1.gate * self.txt_attn.proj(txt_attn) + txt = txt + txt_mod2.gate * self.txt_mlp((1 + txt_mod2.scale) * self.txt_norm2(txt) + txt_mod2.shift) + return img, txt + + +class SingleStreamBlock(nn.Module): + """ + A DiT block with parallel linear layers as described in + https://arxiv.org/abs/2302.05442 and adapted modulation interface. + """ + + def __init__( + self, + hidden_size: int, + num_heads: int, + mlp_ratio: float = 4.0, + ): + super().__init__() + self.num_heads = num_heads + head_dim = hidden_size // num_heads + + self.mlp_hidden_dim = int(hidden_size * mlp_ratio) + # qkv and mlp_in + self.linear1 = nn.Linear(hidden_size, hidden_size * 3 + self.mlp_hidden_dim) + # proj and mlp_out + self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size) + + self.norm = QKNorm(head_dim) + + self.hidden_size = hidden_size + self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + + self.mlp_act = nn.GELU(approximate="tanh") + self.modulation = Modulation(hidden_size, double=False) + + def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor: + mod, _ = self.modulation(vec) + x_mod = (1 + mod.scale) * self.pre_norm(x) + mod.shift + qkv, mlp = torch.split(self.linear1(x_mod), [3 * self.hidden_size, self.mlp_hidden_dim], dim=-1) + + q, k, v = rearrange(qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + q, k = self.norm(q, k, v) + + # compute attention + attn = attention(q, k, v, pe=pe) + # compute activation in mlp stream, cat again and run second linear layer + output = self.linear2(torch.cat((attn, self.mlp_act(mlp)), 2)) + return x + mod.gate * output + + +class LastLayer(nn.Module): + def __init__(self, hidden_size: int, out_channels: int): + super().__init__() + self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.linear = nn.Linear(hidden_size, out_channels, bias=True) + self.adaLN_modulation = nn.Sequential(nn.SiLU(), nn.Linear(hidden_size, 2 * hidden_size, bias=True)) + + def forward(self, x: Tensor, vec: Tensor) -> Tensor: + shift, scale = self.adaLN_modulation(vec).chunk(2, dim=1) + x = (1 + scale[:, None, :]) * self.norm_final(x) + shift[:, None, :] + x = self.linear(x) + return x + + +def prepare(img: Tensor, patch_size: int = 1) -> dict[str, Tensor]: + bs, c, h, w = img.shape + + # Rearrange the image into patches based on the given patch size + img = rearrange(img, "b c (h ph) (w pw) -> b (h w) (c ph pw)", ph=patch_size, pw=patch_size) + + # Ensure all images in the batch are processed if the input batch size was 1 + if img.shape[0] == 1 and bs > 1: + img = repeat(img, "1 ... -> bs ...", bs=bs) + + # Create image ids for positional encoding + img_ids = torch.zeros(h // patch_size, w // patch_size, 3) + img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // patch_size)[:, None] + img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // patch_size)[None, :] + img_ids = repeat(img_ids, "h w c -> b (h w) c", b=bs) + + return img, img_ids.to(img.device) + + +class PatchEmbed(nn.Module): + """2D Image to Patch Embedding""" + + def __init__( + self, + img_size=224, + patch_size=16, + in_chans=3, + embed_dim=768, + norm_layer=None, + flatten=True, + bias=True, + ): + super().__init__() + img_size = cast_tuple(img_size, 2) + patch_size = cast_tuple(patch_size, 2) + self.img_size = img_size + self.patch_size = patch_size + self.grid_size = (img_size[0] // patch_size[0], img_size[1] // patch_size[1]) + self.num_patches = self.grid_size[0] * self.grid_size[1] + self.flatten = flatten + + self.proj = nn.Conv2d(in_chans, embed_dim, kernel_size=patch_size, stride=patch_size, bias=bias) + self.norm = norm_layer(embed_dim) if exists(norm_layer) else nn.Identity() + + # Initialize patch_embed like nn.Linear (instead of nn.Conv2d): + w = self.proj.weight.data + nn.init.xavier_uniform_(w.view([w.shape[0], -1])) + nn.init.constant_(self.proj.bias, 0) + + def forward(self, x): + x = self.proj(x) + if self.flatten: + x = x.flatten(2).transpose(1, 2) # BCHW -> BNC + x = self.norm(x) + return x + +class MLPEmbedder(nn.Module): + def __init__(self, in_dim: int, hidden_dim: int): + super().__init__() + self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True) + self.silu = nn.SiLU() + self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True) + + def forward(self, x: Tensor) -> Tensor: + return self.out_layer(self.silu(self.in_layer(x))) + +def timestep_embedding(t: Tensor, dim, max_period=10000, time_factor: float = 1000.0): + """ + Create sinusoidal timestep embeddings. + :param t: a 1-D Tensor of N indices, one per batch element. + These may be fractional. + :param dim: the dimension of the output. + :param max_period: controls the minimum frequency of the embeddings. + :return: an (N, D) Tensor of positional embeddings. + """ + t = time_factor * t + half = dim // 2 + freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half).to(t.device) + + args = t[:, None].float() * freqs[None] + embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1) + if dim % 2: + embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1) + if torch.is_floating_point(t): + embedding = embedding.to(t) + return embedding + +class TimestepEmbedder(nn.Module): + def __init__(self, hidden_size, frequency_embedding_size=256): + super().__init__() + self.mlp = MLPEmbedder(frequency_embedding_size, hidden_size) + self.frequency_embedding_size = frequency_embedding_size + + def forward(self, t: Tensor) -> Tensor: + return self.mlp(timestep_embedding(t, self.frequency_embedding_size)) + +def apply_conditional_dropout(tensor, mask, null_tensor=None): + device, dtype = tensor.device, tensor.dtype + mask_shape = [mask.shape[0]] + [1] * (tensor.dim() - 1) + keep_mask = mask.view(*mask_shape) + + if exists(null_tensor): + null_tensor = null_tensor.to(device=device, dtype=dtype) + null_tensor = null_tensor.expand_as(tensor) + else: + null_tensor = torch.zeros_like(tensor) + + return torch.where(keep_mask, tensor, null_tensor) + + +class TryOnModel(nn.Module): + def __init__( + self, + input_shape: Tuple[int] = (864, 576), + hidden_size: int = 1280, + n_heads=10, + double_blocks_depth: int = 8, + single_blocks_depth: int = 16, + mlp_ratio: int = 4, + channels_in: int = 3, + patch_size: int = 12, + theta: int = 10000, + axes_dim: Tuple[int] = (16, 56, 56), + qkv_bias: bool = True, + guidance_embed: bool = False, + n_classes: int = 3, + use_patch_mixer: bool = True, + patch_mixer_depth: int = 4, + ): + super().__init__() + + # time + self.t_embedder = TimestepEmbedder(hidden_size=hidden_size) + + # category labels (tops, bottoms, one-pieces) + self.y_embedder = nn.Embedding(n_classes + 1, hidden_size) if n_classes > 0 else None # +1 for null class + + # guidance embeddings for guidance distillation + self.guidance_embedder = TimestepEmbedder(hidden_size=hidden_size) if guidance_embed else None + + # positional embeddings + pe_dim = hidden_size // n_heads + if sum(axes_dim) != pe_dim: + raise ValueError(f"Got {axes_dim} but expected positional dim {pe_dim}") + self.pe_embedder = EmbedND(dim=pe_dim, theta=theta, axes_dim=axes_dim) + + # images + self.input_shape = input_shape + self.patch_size = patch_size + self.channels_in = channels_in + self.x_embedder = PatchEmbed( + img_size=input_shape, patch_size=patch_size, in_chans=channels_in * 2 + 1, embed_dim=hidden_size, flatten=False + ) + self.garment_embedder = PatchEmbed( + img_size=input_shape, patch_size=patch_size, in_chans=channels_in + 1, embed_dim=hidden_size, flatten=False + ) + + # patch mixer + self.use_patch_mixer = use_patch_mixer + if use_patch_mixer: + self.x_patch_mixer = nn.ModuleList( + [SingleStreamBlock(hidden_size, n_heads, mlp_ratio=mlp_ratio) for _ in range(patch_mixer_depth)] + ) + # Buffer kept for checkpoint compatibility (not used at inference) + self.register_buffer("patch_mixer_token", torch.zeros(1, 1, channels_in * self.patch_size**2)) + + # core MMDiT + self.double_blocks = nn.ModuleList( + [ + DoubleStreamBlock( + hidden_size, + n_heads, + mlp_ratio=mlp_ratio, + qkv_bias=qkv_bias, + ) + for _ in range(double_blocks_depth) + ] + ) + + self.single_blocks = nn.ModuleList( + [SingleStreamBlock(hidden_size, n_heads, mlp_ratio=mlp_ratio) for _ in range(single_blocks_depth)] + ) + + self.final_layer = LastLayer(hidden_size, out_channels=channels_in * self.patch_size**2) + + # forward with classifier free guidance + + def forward_for_cfg(self, *args, **kwargs): + # cleanup kwargs + kwargs = compact(kwargs) + + # infer batch size from the first tensor argument + noisy_images = args[0] + batch_size = noisy_images.shape[0] + + # duplicate all tensor arguments and keyword arguments + duplicated_args = [torch.cat([arg, arg], dim=0) if isinstance(arg, torch.Tensor) else arg for arg in args] + duplicated_kwargs = { + k: torch.cat([v, v], dim=0) if isinstance(v, torch.Tensor) else v for k, v in kwargs.items() + } + + # prepare cond drop masks for the duplicated inputs + mask = torch.cat( + [ + torch.ones(batch_size, device=noisy_images.device, dtype=torch.bool), + torch.zeros(batch_size, device=noisy_images.device, dtype=torch.bool), + ], + dim=0, + ) + + # add cond_drop_probs to duplicated_kwargs + duplicated_kwargs["mask"] = mask + + # execute the forward pass with duplicated inputs + all_logits = self.forward(*duplicated_args, **duplicated_kwargs)["x"] + + # split the logits into original and null versions + logits, null_logits = all_logits.split(batch_size) + + return {"v_c": logits, "v_u": null_logits} + + def forward( + self, + x, + times, + ca_images, + garment_images, + person_poses, + garment_poses, + mask: Optional[torch.Tensor] = None, + guidance: Optional[torch.Tensor] = None, + garment_categories: Optional[torch.Tensor] = None, + ): + ###################### CLASSIFIER FREE GUIDANCE ###################### + + batch_size, device = x.shape[0], x.device + + # if mask is not provided, create a boolean mask of all true + if not exists(mask): + mask = torch.ones(batch_size, device=device, dtype=torch.bool) + + ####################### 2D IMAGES TO SEQUENCE ######################## + + ca_images = apply_conditional_dropout(ca_images, mask) + person_poses = apply_conditional_dropout(person_poses, mask) + x = torch.cat([x, ca_images, person_poses], dim=1) + x = self.x_embedder(x) + x, x_ids = prepare(x) + + garment_poses = apply_conditional_dropout(garment_poses, mask) + garment_images = apply_conditional_dropout(garment_images, mask) + garment_images = torch.cat([garment_images, garment_poses], dim=1) + garment_images = self.garment_embedder(garment_images) + garment_images, garment_ids = prepare(garment_images) + + ###################### TIME & MODULATION ###################### + + t = self.t_embedder(times) + + if exists(self.guidance_embedder): + assert exists(guidance), "Guidance scale required for guidance distilled model" + t = t + self.guidance_embedder(guidance) + + if exists(self.y_embedder): + assert exists(garment_categories), "Category labels required for y_embedder" + y = apply_conditional_dropout(garment_categories, mask) + t = t + self.y_embedder(y) + + ###################### POSITIONAL EMBEDDINGS ###################### + + img, txt, vec = x, garment_images, t # name change for consistency with the original code + + x_pe = self.pe_embedder(x_ids) + g_pe = self.pe_embedder(garment_ids) + + ###################### PATCH MIXER ###################### + + if self.use_patch_mixer: + for block in self.x_patch_mixer: + img = block(img, vec=vec, pe=x_pe) + + ###################### CORE MMDiT ######################## + + pe = torch.cat([x_pe, g_pe], dim=2) + + for block in self.double_blocks: + img, txt = block(img=img, txt=txt, vec=vec, pe=pe) + + img = torch.cat((txt, img), 1) + for block in self.single_blocks: + img = block(img, vec=vec, pe=pe) + img = img[:, txt.shape[1] :, ...] + + x = self.final_layer(img, vec) + + ###################### SEQUENCE TO 2D IMAGES ######################## + + x = rearrange( + x, + "b (h w) c -> b c h w", + h=self.input_shape[0] // self.patch_size, + w=self.input_shape[1] // self.patch_size, + ) + if self.patch_size > 1: + x = unpack_images(x, self.patch_size) + + return {"x": x} diff --git a/fashn_vton/utils/__init__.py b/fashn_vton/utils/__init__.py new file mode 100644 index 0000000..2757c66 --- /dev/null +++ b/fashn_vton/utils/__init__.py @@ -0,0 +1,40 @@ +""" +Utility functions for FASHN VTON. + +This package provides common utilities: +- Common Python helpers (exists, default, cast_tuple, compact) +- Model checkpoint loading +- Tensor operations and conversions +- Sampling schedules for Rectified Flow +- Pose keypoint utilities +- Logging setup +""" + +from .checkpoint import load_checkpoint +from .common import cast_tuple, compact, default, exists +from .keypoints import get_dummy_dw_keypoints +from .logger import setup_logger +from .sampling import get_rf_schedule, time_shift +from .tensor import normalize_uint8_to_neg1_1, numpy_to_torch, tensor_to_pil, unpack_images + +__all__ = [ + # Common helpers + "exists", + "default", + "cast_tuple", + "compact", + # Checkpoint loading + "load_checkpoint", + # Tensor operations + "numpy_to_torch", + "unpack_images", + "normalize_uint8_to_neg1_1", + "tensor_to_pil", + # Sampling + "time_shift", + "get_rf_schedule", + # Pose + "get_dummy_dw_keypoints", + # Logging + "setup_logger", +] diff --git a/fashn_vton/utils/__pycache__/__init__.cpython-312.pyc b/fashn_vton/utils/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..21fd330b9d137aaefc437d722ac2aee7ca1cd4f0 GIT binary patch literal 1031 zcmYjPzi-qq6n5@c?sCmte}SlA@YoZ*p1M^CAt9s+BxozzGNgkQCC;67b{xe{%fZB7 z!H!t?TNt}psyZ;S6)F}co|C&451*c&?e~4(^ZV}m9)eZ-`jK7N5&B^_i&dKkHyVP^ zC`JK_ow&vwQ44C0jqAKF8bL!egQjQ&E#U@knQ!p6=mecIZgNj_gKinO_>%AgU-W`r zxp#SAECa7=NQ%QF^Jg(E41OmR`ZXH-4$hWJI5JDlkW98D9Ij&pCE|jENbBV-1!D=LG2Y|y9PS-TMe&r*DvnzNaPnG? z$7KNu;=G^GA2e`G-^w6&vIY&~-8nGs+<|fD{!FVf3{UTmsU~qD#Eit}WWlN6WJy^L z!hwDS0*oiheG7dHOBQ+-x)$sh=WPoefc?Qe z;}R0fh!AMR?QfMuOskrU|7w?1meh1^8&Fm=t;&5_)wI?8`zkt1Y+99-N>3@T6lFD9 z2_ff%d_y;|MSl-Q=Q5uv@JdH8hso^Z)hH^ImV%@(S89acHKZiN?^BftdfDFM9t3pBGt5^`(8mI)5iXQpLJq*=40ct*S*W(^3D0XftWS zi^7>!vZ|(+Jh9N~I<0tN!(6e7rWYnAZ4kRCb|J#T;G+Qh4u28=E(IAMOZ{3s8EBsx zZM4ZOVUSWAE%nm(MSM1do`y4UhKAVFAeU&gXS&3T+}rtU8?{;Swt}00nT>gLhR4u5 zj|rOabrI8T3tQpMZf5g{CVMAFq>X+>zrDlpy-l=`{VmvZI$w zYT6Daj<4T6?vF^!I#%x9X0A-NtX@;bO1e|AGrE=ae^w~AGoP#1$I?Eoo21;u1WBG^ zZLSNGrv@}WonJQ^#{u>}$#cqWPIiT2-YyEQn0>C9*pEHOTcI!f0@H&YG7ZI{IyDu; zDpCvEI%)9`#{pKW;~*3)pBBA%O|NTEe9-aYP17n@sH0)K+B%M_ebq_}1iEHbxo_&O`3vO05!})7Y_^HXvQuZK*;}|)YTdt^ zEjApAYnsjY?ev71Jg3g$!q+oCc{CtT79Kt)d%_g8-tv-wQah26k6AFNptH@U@6gd0 zlH{k&C(WmipFI8{f9Q~AaD zR~B$6@q)l&UXec#83CJNu#qMie;QSDilo>U-ft(%rh|YW2!EsWK8o(6_#5=;f8msH VQ&^urK(M|@`Rn%OBfjSj{|D8R;8g$s literal 0 HcmV?d00001 diff --git a/fashn_vton/utils/__pycache__/common.cpython-312.pyc b/fashn_vton/utils/__pycache__/common.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..a68ba61fef3177a784fc1984aa88873f09212f32 GIT binary patch literal 1720 zcmY*ZO=ufO6rR~#tz^kRwd{u0?#~9>X|}B`grOyINhx*>4Uy8;X%C`BD7&M0YLF|>IXaT&71dT z-}~Ns^UJ_M6aguBHXN=Z^oQ7VDcliSMG*FpjWWo_w&LP~l2Hn3MlEO=tq{tDFtSym z*c#FEnjPBGGGV#brB2D~b{KRKdq8UYh(`Keh8w{?1ZUB$P$p`}hzffZ{QJQ+R*sm1 z>&CoaDEOZ7ahWdzD{;qlc-hF8Jc~QN$7Y_108K4WKk&+dw%}Mih+Hj-vz!}*u9b=| z3Di}PbxL@NdKMNA9a4Zp#;7F#*hdw}t%R45sjQFBFB5CUaPkKDmWbgn!}Gb3_B}F_ z{sEbo3S^kL%=kBzSo7%$E9NYcfOzxe&pt|6CC2?i*7I$`y2|Or99#CLKfiW0oyZF@ zyUKkpA(CPVOC~x~EC)LI%3++*ez;s0e-XkadJ-F~+1u|ow6PZKQGjhr#NQ>6hs*$w21{Co6rB5Yh0s{d>7lF48;xb~`B%dp} z+&J%3!^*i}!iAL(bJtyB&X|hKA&B%;iiC|0!jG5;-Y{qkSP<^qMTcQ0H0&%ao9JnH zVB6Z-_-!b@9X*MU)suT~?Id?+8`|VS5*9iDR_Q=@iFbo9pMoO+6%DckEfVV+M-tN>b;)dN9H?9E=EgQ7#- zK3+h#@omLY9b_qAjw190l)H+dB+n4T8NNufQzfb2L#6{stu9E9XsavIn$%so6DoEl*E zI(%#v7MON(_E+=5p?TrhoI8%C4$ZmmF4kucLJwCTk)NvD^R*XuE;r1%$FWpHOZ{^c zOR)&}?TsY$yP>3R;jEIVtQI@1VsFC*yzFl(3B{~HM0(<7e(o`|VzoT=1qUj?v-P9F4UQ?S0SY2xg z){JU+sy5O@!0K&wFwzpczXqc?UYl~NhXObbwSf*B3 z!Gr%nFCIL2DGK^0cohXLz0|q(w6{V%dC`|~w^|W=koS_8yzhNql8=Lf{Rqr`^_cg$ z2<_NK&tRR~-Wdq%h@u9f4s}DP;nD&tG+pXF^BP|2R*I`*Gc1iI7X~MWhumyTJ^mNff2Het*siSni6`nDSf*QE4u(84^#pK@yWWl+{)Li$-}-Qh2Dc<$?n#4B>){R#*GJ=)Au31FYh9 zM&Q0W_AOv;FJG1Nd3HD;z?=f}tfZ4g(3F*a!B^bO6>;WC0o0t#vtL5nQv7|cwj$NC zj!D33*Jad9@7}Bhi8eCwg``aPlGSOVmqhj6om;cDrk(naj1)Ed)pTv2=XjiEqb+9q z{Ox?Y;_q*%Vd!U>>Z{<|X!kf8INGjnpC5a3_HB8i{I0xpadPwAS3XaCnD{c^ z*qT|`oL<;G@t|FQe(LG$_K~6X%pMe@N-;a+`&0(LpA|(C#i<&AbmrN=kvG~v>KJ6y qkWKeZMUo~VyQW5AX?5$9;C2g+<9tPzcF@RfX}}qN>3u^G^1`3-xBtZe literal 0 HcmV?d00001 diff --git a/fashn_vton/utils/__pycache__/logger.cpython-312.pyc b/fashn_vton/utils/__pycache__/logger.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..53f852b6bae39811c3fee8310e6e34157b856cfe GIT binary patch literal 2724 zcmZ`*O>7&-6`uX&@>dp3>(>$pN3={Bl4?6h?flenWYJb?Q;6gw2n}Sxa+dTmyIgj5 z=~(m%Q2_y=iw3a|fn*>+bjTrw12j3s$KHD>VHIK~3LqfK!8aLp(4eQj+2u-#kq*H3 zX7;^#GxO&A-teFCcmzS?KfkH>%Lx6z20;sVJ5O#yXBA;oK$ye4!4)`9^93HXU3c?|cuO@bFb-=G9MMEAItV5lg?R&e z{z0#-;Mj^(h%fTlMDvwPR;8lXD(Qx!8@i)wcDihlbne{3#eDj$D|7ku`?_;2U9t>| zXc%IDGYYrZ+g{s}p83G<@~MOJ`1)S&2&L zXI^{r0u^WTxj7no``mnfHh+OiGxPIv^HiChpS?0WeeMzsJG!aaPSLE>5H31e*>pf@I?-r$Kze0Rp%s!R3gAc~@w{w9<>=i$0PNas`*a&->pvw1~1ozIilr ztX4F&teu`nXO0=#bYT7c(i*L?OmAr(iH4AHY z@Nptnw6E1B7-FW%tgBvktlAVql|5wu#xycnUnIQ{xH<65jZ6kROM%rM1M$l;>Lgxh z3ExIjo#c`Ag?92-YkEtG-wL&rfwhBcm47QopMW2tGSNyF!Av_1QY&?{#%ze4!pr?h z042-4%T+zYuf-DY?B1BmJM6~17Tf?Cbwa_nsuJ|}w96RGe<=ifbUAg`SPQxQ{;}g# z3TD0~!(9VSUG6@k3^ay|-{ScaprU%f_t`J22ltQszWaZCvB)YyaH1v2m3;I;WR~=2 z8p9=UN6J59$+!X-lJy$aYJd^jB98i=wrCSp*|Je8n0AFmbX%_h3u+~eidZi>RIJ)o zjY_y-)@{NzPi3epz(5+_#V(bn=Pu37FHoVPIaHdTS(v$!4G~6f($A*EETThkDjF(V zB<=@rG|YzNInTo@OlqBIW&H;6@WbwkVHeO#4zW;2Ks5WF#hf2(2yFIE5DansBdg6j z%~otHdg!+C`T31#c586tv!S~~>z5w%{sV6e{;`$&==JTvOe^=d*MA%sxf{BhSik;o zV6qkOq=q(AW9`&fE8OWHUcGte=IRG`K3HG;YV4ciUmt%^Zl9ca*q>{~Iz7W{X1nM3 z7x?ehd)0Q&>3=DwNgu4A<#Wy6%sUeqb0UM&nTs!GF29&r$i^6L8P8RfMpV_baKm8z zn5zD{QS>T+suD&Saufs=|72MP6>NuiRHGqAJt*%q40V3qql%Xg8t;<5q!|X31kTSL zL*lD`)>s+|6AZET8z6pJLEF8<}c zgM&XawG)d-XV%`{9GPs7Ozt2Ue0AzSOg(18JGi$771}gjD_U5h33gt7+756;Y>!Ak!Enug>Rsr5&FN3*k zgiOI)8U|Xi=UpR&rGP3njM>9Mm21TsHZ)?>_<}>UqIuCD`|zuo7OMv54B%`{vm zR=rq(cP=ZFlMvH?Lr$^ivR~zCs2iJx1HKZQz5E^}(;m1eTcJ@`{TbveSjXXKTOgLv zQyC==ZN^92@zFKsVSH>k_hTsD(pHu_O5)?5TRm&ihBCSx?fv-dU(aqO2RD=B?d15w zWVRLBN literal 0 HcmV?d00001 diff --git a/fashn_vton/utils/__pycache__/sampling.cpython-312.pyc b/fashn_vton/utils/__pycache__/sampling.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..1f521bffe49a29d8ffdc380aba25711f8d85f10d GIT binary patch literal 1953 zcma)7O>7%Q6rSC+9mn}IhyVpC8jz4;XyX_fltlS~RuoyPQmJ+kM5?Tfch=r?|1vX9 z>adY=C{ij8q+AjN5<%jSCi8`Nb z8n&g8IQzYsG&=6YX5&Vekt7M}rIU2P*GZzC19Q-)?!;$1>uRQZdAOiEzHPY`T;rB) zaf>i)dK6zLWp0@kG4K`JTf&xW5=z`M89EXH%!H~_3**eHIC`k?FhL2gQMatMqeC%R zi~WfY(+1*7Q#Y?RS8W-f-G=`EerG6wy4;1JYRgc|Kv{+gErUbxu7EPC-VY8_7{>d) zZ`U!m9D-TZGCAg++%Zo4C|Oga=;*v$6@_5sDluv{8A?Uww8F;aN;q?B; z2qzQ|mOYnK&t@%q3-;Vw?F66VIoq@iOZld zOBS!4xh)ln^C0KWfM*XTM+3&3q|fSTN%fZsB}gRV~}w*)W~f=qfyZ* zDq2PXsfdX!Wc%l}?2<=sF<&o}>}Ag}>o@Y*a*c7%DY>3OSo7tDD>|#X7d|Rpo5`AD zSz6?tn-!R1S=NBqkY5i|B9@Zu1MP-G1g=>Qrd9OxWz?PiV)X9l`lZh&zPb4A=vSj# zm%g6Z&TTL3jNH%djP3RBzd5!4>ePPM^n=*+2^gWU)2%rrnOoRF$s(bQ`~!sv_(-Dg zOIU26K&4usbfFdHzi}ow7zk{E)vmCIv7&KN8#w~S&;qTk3AFi+rmih_8_N|9&P@iu zX86TFlPoNtKm(j6^q-qJ{gAkX>YTLN)ke}8jBFJ(>iuO(z?5M131VwpEMfqu#%fr~5=h* z2f&HAm)IOk$C{!jkI=h^sOu1QA1At%_muUKV@~ literal 0 HcmV?d00001 diff --git a/fashn_vton/utils/__pycache__/tensor.cpython-312.pyc b/fashn_vton/utils/__pycache__/tensor.cpython-312.pyc new file mode 100644 index 0000000000000000000000000000000000000000..5e43df5606865ac3d76f9d8f63f901a4571330b5 GIT binary patch literal 3085 zcmb7GO>7fK6rS~O;y*}2AV8p#mR2!x9FjIw8xSd?LPTm)3P~#&g1hmq?M>`mYj!sw z!L8B?spQo36mtTkN)*vUIMJR;Pd)U|pj0Z|f`r5=H=}Uk)c0n+YbQYAp*nm&1L09kdUYN;SBiZWBn6>kR>9K6p;kUQxH-f{`RK4@b(sbMSsdK zpw3qa6oaWCm;HsNVsok);J(n1a?s= z{J-rYU>B2G0O^*9q^P_DP7O}R9E|mMHXy~W_)|Nj)@||bkXyFJ9FTT`b?dAr-Zqy! zGdrrWOr9FDs%wmzhEh-rMb>E+zLcVvlWB=*8ClmAHJ4mN5F@t*tC`6nW1^aq3@u$!3TbX6`R?F(FmR7^B<>dv!loe#Rx}NW8Q687OieYOB-m4=s8VWL0BSXs5Wz9B-9+71|;&% z-RDUf(O6L|D04D371eml;gEfyFiiCPkp%4@7#K%_obTyN(7tiXFVB_j#pkm}HF5*JuIOzN0#%FuUfuj;s% z<}PCFRXNv}?qeOGKY+_|;XJACB9Z7-bK&hpePL+Dw~t{FwnAtxOHdbb{~BRf4-tkE z!6V%7Tu&!#E)v&0j48y|4JnJj7BzpK46!}%1upG{SEEa@ngi&{5%6O_=nvzX7I@5) zCz0KimW7c@=0Uh?#n)xK5jIu#_zbec8ORdOlztF^DFO@Ei&{egBL=SGqdp6M3Uj~m zbU4m#D$3yDnc?>+ud-V*rlCaCIP>@0?bee9CxB>KI?7BrK{K^7X!Etk(M_SHS%vAw zHoS8Y@`IoVU_@B}9<_qNIzg_L1e;b-8!V;(x&}K4`d(av3+KtR-K44QQLM9aZZ+0* z)%z&Cr{Y}=cdqz4YoscPMkb%u;o@yb1|G?iK|yHjE_i{<@b{hPjt^8YkYeIzPA3>s zp+ZeLO%FGR3pasN5QnT;DA+2!mVz-|mZ+FDWJV3R%sMp8BFOb_iDph%IabQ#5|edV zHAF+vRGml4IRMI|p@^H#*w_i6FpSz&rJmjiHwchJ=pw3I(CK05FivbP_9tjQL1*GF z0qVLzdJfAL?8fK7T@#&>Oa{#!<}y?8-X`Dw2?-p%0Sw^S@NI*dJ2XMNGqlu4OZ{=M zn4vw#=>*Nt?mSG!Y4;2&;vUOW>VyA&hUo=XVVPnt!DAz=dFE&-Ku?P?dmq!@O0#Szbr6`dUcXf1~|Y=grP40de`&dx^V=brh@nJi^Nr z^LqJOxk^A>_TP)#jjW^iEEE+EESgK@uR#Uk@*)1ZKk)O>pN_8M%rhDjE((idOBcSr XP$ejTtK0AI2Yx*E!?EXlj(hYMSqb!n literal 0 HcmV?d00001 diff --git a/fashn_vton/utils/checkpoint.py b/fashn_vton/utils/checkpoint.py new file mode 100644 index 0000000..7155575 --- /dev/null +++ b/fashn_vton/utils/checkpoint.py @@ -0,0 +1,44 @@ +"""Model checkpoint loading utilities.""" + +import os + +import torch +from safetensors.torch import load_file + + +def load_checkpoint(checkpoint_path: str, device: str = "cpu") -> dict: + """ + Load model checkpoint from local file or HuggingFace Hub. + + Supports: + - Local .pt, .pth files (PyTorch format) + - Local .safetensors files + - HuggingFace repo IDs (e.g., "fashn-ai/fashn-vton-1.5") + + Args: + checkpoint_path: Local file path or HuggingFace repo ID + device: Device to load the checkpoint to + + Returns: + The loaded state dictionary + """ + # Check if it's a local file + if os.path.isfile(checkpoint_path): + if checkpoint_path.endswith(".pt") or checkpoint_path.endswith(".pth"): + return torch.load(checkpoint_path, map_location=device, weights_only=False) + elif checkpoint_path.endswith(".safetensors"): + return load_file(checkpoint_path, device=device) + else: + raise ValueError(f"Unknown checkpoint file format: {checkpoint_path}") + + # Check if it looks like a HuggingFace repo ID + if "/" in checkpoint_path and not checkpoint_path.endswith((".pt", ".pth", ".safetensors")): + from huggingface_hub import hf_hub_download + + local_path = hf_hub_download( + repo_id=checkpoint_path, + filename="model.safetensors", + ) + return load_file(local_path, device=device) + + raise ValueError(f"Checkpoint not found: {checkpoint_path}") diff --git a/fashn_vton/utils/common.py b/fashn_vton/utils/common.py new file mode 100644 index 0000000..06f3716 --- /dev/null +++ b/fashn_vton/utils/common.py @@ -0,0 +1,33 @@ +"""Common Python utility functions.""" + +from typing import Any, Dict, Optional, Tuple + + +def exists(val: Any) -> bool: + """Check if value is not None.""" + return val is not None + + +def default(val: Any, d: Any) -> Any: + """Return val if not None, else default (or call it if callable).""" + if exists(val): + return val + return d() if callable(d) else d + + +def cast_tuple(val: Any, length: Optional[int] = None) -> Tuple: + """Convert value to tuple with optional length validation.""" + if isinstance(val, list): + val = tuple(val) + + output = val if isinstance(val, tuple) else ((val,) * default(length, 1)) + + if exists(length): + assert len(output) == length + + return output + + +def compact(input_dict: Dict) -> Dict: + """Filter None values from dictionary.""" + return {key: value for key, value in input_dict.items() if exists(value)} diff --git a/fashn_vton/utils/keypoints.py b/fashn_vton/utils/keypoints.py new file mode 100644 index 0000000..0cc6d69 --- /dev/null +++ b/fashn_vton/utils/keypoints.py @@ -0,0 +1,18 @@ +"""Keypoint utilities for pose detection.""" + +import numpy as np + + +def get_dummy_dw_keypoints() -> dict: + """ + Get dummy DWPose keypoints dictionary for flat-lay garments. + + Returns a pose dictionary with all keypoints set to -1 to indicate + no person is present (used for flat-lay garment images). + + Returns: + Dictionary with 'bodies' key containing dummy keypoints + """ + pose = {} + pose["bodies"] = {"candidate": (-1) * np.ones((18, 2)), "subset": -1 * np.ones((1, 18))} + return pose diff --git a/fashn_vton/utils/logger.py b/fashn_vton/utils/logger.py new file mode 100644 index 0000000..bb6418d --- /dev/null +++ b/fashn_vton/utils/logger.py @@ -0,0 +1,55 @@ +"""Logging utilities for FASHN VTON with colored console output.""" + +import json +import logging +from typing import Optional + +from .common import exists + + +class CustomFormatter(logging.Formatter): + COLORS = { + "DEBUG": "\033[94m", + "INFO": "\033[0m", + "WARNING": "\033[93m", + "ERROR": "\033[91m", + "CRITICAL": "\033[1;91m", + } + RESET = "\033[0m" + + def __init__(self, timestamp: bool = False, datefmt: str = "%Y-%m-%d %H:%M:%S"): + fmt = "%(name)s - %(levelname)s - %(message)s" + if timestamp: + fmt = "%(asctime)s - " + fmt + super().__init__(fmt, datefmt) + + def format(self, record: logging.LogRecord) -> str: + original_msg = record.msg + if isinstance(original_msg, dict): + record.msg = json.dumps(original_msg, indent=4, sort_keys=True) + else: + record.msg = original_msg + + formatted_msg = super().format(record) + levelname = record.levelname + color_prefix = self.COLORS.get(levelname, self.COLORS["INFO"]) + return color_prefix + formatted_msg + self.RESET + + +def setup_logger( + name: str, timestamp: bool = False, level: Optional[int] = None +) -> logging.Logger: + logger = logging.getLogger(name) + + if exists(level): + logger.setLevel(level) + + if not logger.handlers: + handler = logging.StreamHandler() + formatter = CustomFormatter(timestamp=timestamp) + handler.setFormatter(formatter) + logger.addHandler(handler) + + logger.propagate = False + + return logger diff --git a/fashn_vton/utils/sampling.py b/fashn_vton/utils/sampling.py new file mode 100644 index 0000000..b42dee0 --- /dev/null +++ b/fashn_vton/utils/sampling.py @@ -0,0 +1,43 @@ +"""Sampling utilities for Rectified Flow inference.""" + +import math + +import torch + + +def time_shift(mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor: + """ + Apply time shift to timesteps for flow matching schedule. + + Args: + mu: Time shift parameter (controls schedule steepness) + sigma: Sigma parameter (typically 1.0) + t: Timestep tensor with values in (0, 1] + + Returns: + Shifted timesteps + """ + return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma) + + +def get_rf_schedule(num_steps: int, mu: float = 1.5, reverse: bool = True) -> list[float]: + """ + Generate timestep schedule for Rectified Flow sampling. + + Creates a shifted linear schedule that provides better sample quality + by spending more time at higher noise levels. + + Args: + num_steps: Number of sampling steps + mu: Time shift parameter (higher = more time at high noise) + reverse: If True, returns schedule from t=0 to t=1 (for denoising) + + Returns: + List of timesteps of length num_steps + 1 + """ + if reverse: + mu = -mu + timesteps = torch.linspace(1, 0, num_steps + 1) + timesteps = time_shift(mu, 1.0, timesteps) + timesteps = timesteps.tolist() + return timesteps[::-1] if reverse else timesteps diff --git a/fashn_vton/utils/tensor.py b/fashn_vton/utils/tensor.py new file mode 100644 index 0000000..046aab4 --- /dev/null +++ b/fashn_vton/utils/tensor.py @@ -0,0 +1,77 @@ +"""PyTorch tensor utilities for image processing.""" + +import numpy as np +import torch +from einops import rearrange +from PIL import Image +from torchvision.transforms.functional import to_pil_image + + +def numpy_to_torch(img: np.ndarray) -> torch.Tensor: + """ + Convert numpy image to torch tensor. + + For 3D arrays (H, W, C), permutes to (C, H, W). + For 2D arrays (H, W), passes through unchanged. + + Args: + img: Input numpy array of shape (H, W, C) or (H, W) + + Returns: + Torch tensor of shape (C, H, W) or (H, W) + """ + t = torch.from_numpy(img) + if t.ndim == 3: + t = t.permute(2, 0, 1) + return t + + +def normalize_uint8_to_neg1_1(x: torch.Tensor) -> torch.Tensor: + """ + Normalize uint8 image tensor from [0, 255] to [-1, 1] range. + + Args: + x: Input tensor with values in [0, 255] + + Returns: + Normalized tensor with values in [-1, 1] + """ + return x / 127.5 - 1.0 + + +def _neg1_1_to_0_1(normed_img: torch.Tensor) -> torch.Tensor: + """Convert [-1, 1] normalized tensor to [0, 1] range.""" + return (normed_img + 1) * 0.5 + + +def tensor_to_pil(img: torch.Tensor, unnormalize: bool = False) -> Image.Image: + """ + Convert PyTorch tensor to PIL Image. + + Args: + img: Input tensor of shape (C, H, W) + unnormalize: If True, convert from [-1, 1] to [0, 1] range first + + Returns: + PIL Image + """ + if unnormalize: + img = _neg1_1_to_0_1(img) + return to_pil_image(img) + + +def unpack_images(x: torch.Tensor, patch_size: int = 2) -> torch.Tensor: + """ + Unpack image patches back to full images. + + Used after transformer processing to convert patch representations + back to spatial images. + + Args: + x: Tensor of shape (batch_size, channels * patch_size^2, h, w) + patch_size: Size of patches used during packing + + Returns: + Tensor of shape (batch_size, channels, h * patch_size, w * patch_size) + """ + return rearrange(x, "b (c p1 p2) h w -> b c (h p1) (w p2)", p1=patch_size, p2=patch_size) diff --git a/nodes.py b/nodes.py new file mode 100644 index 0000000..a5903fc --- /dev/null +++ b/nodes.py @@ -0,0 +1,153 @@ +import os +import torch +import numpy as np +from PIL import Image +import folder_paths +import comfy.utils +import comfy.model_management +from .fashn_vton import TryOnPipeline + +model_list = [ + 'fashn-ai/fashn-vton-1.5' +] + +class FashnVtonLoader: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "model": (model_list, {"default": 'fashn-ai/fashn-vton-1.5'}) + } + } + + RETURN_TYPES = ("FASHN_VTON_PIPELINE",) + RETURN_NAMES = ("pipeline",) + FUNCTION = "load_pipeline" + CATEGORY = "FashnAI" + + def load_pipeline(self, model): + weights_name = "fashn-vton" + + base_weights_dir = os.path.join(folder_paths.models_dir, weights_name) + + os.makedirs(base_weights_dir, exist_ok=True) + + from huggingface_hub import hf_hub_download + + # Download TryOnModel + tryon_path = os.path.join(base_weights_dir, "model.safetensors") + if not os.path.exists(tryon_path): + print(f"FashnVTON: Downloading TryOnModel weights to {tryon_path}...") + hf_hub_download( + repo_id=model, + filename="model.safetensors", + local_dir=base_weights_dir, + ) + + # Download DWPose + dwpose_dir = os.path.join(base_weights_dir, "dwpose") + os.makedirs(dwpose_dir, exist_ok=True) + for filename in ["yolox_l.onnx", "dw-ll_ucoco_384.onnx"]: + if not os.path.exists(os.path.join(dwpose_dir, filename)): + print(f"FashnVTON: Downloading DWPose/{filename} to {dwpose_dir}...") + hf_hub_download( + repo_id="fashn-ai/DWPose", + filename=filename, + local_dir=dwpose_dir, + ) + + # Initialize Pipeline + print(f"FashnVTON: Loading pipeline from {base_weights_dir}...") + pipeline = TryOnPipeline(weights_dir=base_weights_dir) + + return (pipeline,) + +class FashnVtonInference: + @classmethod + def INPUT_TYPES(s): + return { + "required": { + "pipeline": ("FASHN_VTON_PIPELINE",), + "person_image": ("IMAGE",), + "garment_image": ("IMAGE",), + "category": (["tops", "bottoms", "one-pieces"], {"default": "tops"}), + "num_timesteps": ("INT", {"default": 30, "min": 1, "max": 100, "step": 1}), + "guidance_scale": ("FLOAT", {"default": 2.0, "min": 1.0, "max": 10.0, "step": 0.1}), + "seed": ("INT", {"default": 0, "min": 0, "max": 0xffffffffffffffff}), + "keep_model_loaded": ("BOOLEAN", {"default": True}), + } + } + + RETURN_TYPES = ("IMAGE",) + FUNCTION = "process" + CATEGORY = "FashnAI" + + def process(self, pipeline, person_image, garment_image, category, num_timesteps, guidance_scale, seed, keep_model_loaded): + + device = comfy.model_management.get_torch_device() + + print(f"FashnVTON: Moving models to {device}...") + if hasattr(pipeline, "tryon_model"): + pipeline.tryon_model.to(device) + if hasattr(pipeline, "hp_model"): + if hasattr(pipeline.hp_model, "model"): + pipeline.hp_model.model.to(device) + + pbar = comfy.utils.ProgressBar(num_timesteps) + + def progress_callback(step, total_steps): + pbar.update_absolute(step + 1, total_steps) + + # ComfyUI images are (B, H, W, C) tensors in [0, 1] + def tensor_to_pil(tensor): + img = tensor[0].cpu().numpy() + img = (img * 255).astype(np.uint8) + return Image.fromarray(img) + + person_pil = tensor_to_pil(person_image) + garment_pil = tensor_to_pil(garment_image) + + seed = seed % (2**32) + + try: + result = pipeline( + person_image=person_pil, + garment_image=garment_pil, + category=category, + num_timesteps=num_timesteps, + guidance_scale=guidance_scale, + seed=seed, + callback=progress_callback, + ) + finally: + # Handle Offloading + if not keep_model_loaded: + print("FashnVTON: Unloading models from VRAM...") + + if hasattr(pipeline, "tryon_model"): + pipeline.tryon_model.to("cpu") + if hasattr(pipeline, "hp_model"): + if hasattr(pipeline.hp_model, "model"): + pipeline.hp_model.model.to("cpu") + + if torch.cuda.is_available(): + torch.cuda.empty_cache() + torch.cuda.ipc_collect() + + comfy.model_management.soft_empty_cache() + + # Convert back to ComfyUI format (B, H, W, C) + output_img = np.array(result.images[0]).astype(np.float32) / 255.0 + output_tensor = torch.from_numpy(output_img).unsqueeze(0) + + return (output_tensor,) + +NODE_CLASS_MAPPINGS = { + "FashnVtonLoader": FashnVtonLoader, + "FashnVtonInference": FashnVtonInference, +} + +NODE_DISPLAY_NAME_MAPPINGS = { + "FashnVtonLoader": "(Down)load Fashn VTON", + "FashnVtonInference": "Fashn VTON Inference", +} \ No newline at end of file diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..4ecd92e --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,17 @@ +[project] +name = "comfyui-fashn-vton" +version = "1.0.0" +description = "Implements the FASHN VTON v1.5 model for virtual try-on in ComfyUI" +readme = "README.md" +license = {file = "LICENSE"} +classifiers = [] +dependencies = [] + + +[project.urls] +Repository = "https://github.com/drphero/ComfyUI-FASHN-VTON" + + +[tool.comfy] +PublisherId = "drphero" +DisplayName = "ComfyUI-FASHN-VTON" \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..503c51b --- /dev/null +++ b/requirements.txt @@ -0,0 +1,10 @@ +safetensors>=0.3.0 +huggingface_hub>=0.20.0 +pillow>=9.0.0 +numpy>=1.21.0 +opencv-python>=4.5.0 +tqdm>=4.65.0 +einops>=0.6.0 +onnxruntime-gpu +matplotlib>=3.5.0 +fashn-human-parser>=0.1.1