diff --git a/.gitignore b/.gitignore
new file mode 100644
index 0000000..c08890e
--- /dev/null
+++ b/.gitignore
@@ -0,0 +1,3 @@
+wip/
+.vscode/*
+*.pyc
diff --git a/LICENSE b/LICENSE
index 15744d0..3877ae0 100644
--- a/LICENSE
+++ b/LICENSE
@@ -1,21 +1,674 @@
-MIT License
-
-Copyright (c) 2024 fractal noise
-
-Permission is hereby granted, free of charge, to any person obtaining a copy
-of this software and associated documentation files (the "Software"), to deal
-in the Software without restriction, including without limitation the rights
-to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-copies of the Software, and to permit persons to whom the Software is
-furnished to do so, subject to the following conditions:
-
-The above copyright notice and this permission notice shall be included in all
-copies or substantial portions of the Software.
-
-THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-SOFTWARE.
+ GNU GENERAL PUBLIC LICENSE
+ Version 3, 29 June 2007
+
+ Copyright (C) 2007 Free Software Foundation, Inc.
+ Everyone is permitted to copy and distribute verbatim copies
+ of this license document, but changing it is not allowed.
+
+ Preamble
+
+ The GNU General Public License is a free, copyleft license for
+software and other kinds of works.
+
+ The licenses for most software and other practical works are designed
+to take away your freedom to share and change the works. By contrast,
+the GNU General Public License is intended to guarantee your freedom to
+share and change all versions of a program--to make sure it remains free
+software for all its users. We, the Free Software Foundation, use the
+GNU General Public License for most of our software; it applies also to
+any other work released this way by its authors. You can apply it to
+your programs, too.
+
+ When we speak of free software, we are referring to freedom, not
+price. Our General Public Licenses are designed to make sure that you
+have the freedom to distribute copies of free software (and charge for
+them if you wish), that you receive source code or can get it if you
+want it, that you can change the software or use pieces of it in new
+free programs, and that you know you can do these things.
+
+ To protect your rights, we need to prevent others from denying you
+these rights or asking you to surrender the rights. Therefore, you have
+certain responsibilities if you distribute copies of the software, or if
+you modify it: responsibilities to respect the freedom of others.
+
+ For example, if you distribute copies of such a program, whether
+gratis or for a fee, you must pass on to the recipients the same
+freedoms that you received. You must make sure that they, too, receive
+or can get the source code. And you must show them these terms so they
+know their rights.
+
+ Developers that use the GNU GPL protect your rights with two steps:
+(1) assert copyright on the software, and (2) offer you this License
+giving you legal permission to copy, distribute and/or modify it.
+
+ For the developers' and authors' protection, the GPL clearly explains
+that there is no warranty for this free software. For both users' and
+authors' sake, the GPL requires that modified versions be marked as
+changed, so that their problems will not be attributed erroneously to
+authors of previous versions.
+
+ Some devices are designed to deny users access to install or run
+modified versions of the software inside them, although the manufacturer
+can do so. This is fundamentally incompatible with the aim of
+protecting users' freedom to change the software. The systematic
+pattern of such abuse occurs in the area of products for individuals to
+use, which is precisely where it is most unacceptable. Therefore, we
+have designed this version of the GPL to prohibit the practice for those
+products. If such problems arise substantially in other domains, we
+stand ready to extend this provision to those domains in future versions
+of the GPL, as needed to protect the freedom of users.
+
+ Finally, every program is threatened constantly by software patents.
+States should not allow patents to restrict development and use of
+software on general-purpose computers, but in those that do, we wish to
+avoid the special danger that patents applied to a free program could
+make it effectively proprietary. To prevent this, the GPL assures that
+patents cannot be used to render the program non-free.
+
+ The precise terms and conditions for copying, distribution and
+modification follow.
+
+ TERMS AND CONDITIONS
+
+ 0. Definitions.
+
+ "This License" refers to version 3 of the GNU General Public License.
+
+ "Copyright" also means copyright-like laws that apply to other kinds of
+works, such as semiconductor masks.
+
+ "The Program" refers to any copyrightable work licensed under this
+License. Each licensee is addressed as "you". "Licensees" and
+"recipients" may be individuals or organizations.
+
+ To "modify" a work means to copy from or adapt all or part of the work
+in a fashion requiring copyright permission, other than the making of an
+exact copy. The resulting work is called a "modified version" of the
+earlier work or a work "based on" the earlier work.
+
+ A "covered work" means either the unmodified Program or a work based
+on the Program.
+
+ To "propagate" a work means to do anything with it that, without
+permission, would make you directly or secondarily liable for
+infringement under applicable copyright law, except executing it on a
+computer or modifying a private copy. Propagation includes copying,
+distribution (with or without modification), making available to the
+public, and in some countries other activities as well.
+
+ To "convey" a work means any kind of propagation that enables other
+parties to make or receive copies. Mere interaction with a user through
+a computer network, with no transfer of a copy, is not conveying.
+
+ An interactive user interface displays "Appropriate Legal Notices"
+to the extent that it includes a convenient and prominently visible
+feature that (1) displays an appropriate copyright notice, and (2)
+tells the user that there is no warranty for the work (except to the
+extent that warranties are provided), that licensees may convey the
+work under this License, and how to view a copy of this License. If
+the interface presents a list of user commands or options, such as a
+menu, a prominent item in the list meets this criterion.
+
+ 1. Source Code.
+
+ The "source code" for a work means the preferred form of the work
+for making modifications to it. "Object code" means any non-source
+form of a work.
+
+ A "Standard Interface" means an interface that either is an official
+standard defined by a recognized standards body, or, in the case of
+interfaces specified for a particular programming language, one that
+is widely used among developers working in that language.
+
+ The "System Libraries" of an executable work include anything, other
+than the work as a whole, that (a) is included in the normal form of
+packaging a Major Component, but which is not part of that Major
+Component, and (b) serves only to enable use of the work with that
+Major Component, or to implement a Standard Interface for which an
+implementation is available to the public in source code form. A
+"Major Component", in this context, means a major essential component
+(kernel, window system, and so on) of the specific operating system
+(if any) on which the executable work runs, or a compiler used to
+produce the work, or an object code interpreter used to run it.
+
+ The "Corresponding Source" for a work in object code form means all
+the source code needed to generate, install, and (for an executable
+work) run the object code and to modify the work, including scripts to
+control those activities. However, it does not include the work's
+System Libraries, or general-purpose tools or generally available free
+programs which are used unmodified in performing those activities but
+which are not part of the work. For example, Corresponding Source
+includes interface definition files associated with source files for
+the work, and the source code for shared libraries and dynamically
+linked subprograms that the work is specifically designed to require,
+such as by intimate data communication or control flow between those
+subprograms and other parts of the work.
+
+ The Corresponding Source need not include anything that users
+can regenerate automatically from other parts of the Corresponding
+Source.
+
+ The Corresponding Source for a work in source code form is that
+same work.
+
+ 2. Basic Permissions.
+
+ All rights granted under this License are granted for the term of
+copyright on the Program, and are irrevocable provided the stated
+conditions are met. This License explicitly affirms your unlimited
+permission to run the unmodified Program. The output from running a
+covered work is covered by this License only if the output, given its
+content, constitutes a covered work. This License acknowledges your
+rights of fair use or other equivalent, as provided by copyright law.
+
+ You may make, run and propagate covered works that you do not
+convey, without conditions so long as your license otherwise remains
+in force. You may convey covered works to others for the sole purpose
+of having them make modifications exclusively for you, or provide you
+with facilities for running those works, provided that you comply with
+the terms of this License in conveying all material for which you do
+not control copyright. Those thus making or running the covered works
+for you must do so exclusively on your behalf, under your direction
+and control, on terms that prohibit them from making any copies of
+your copyrighted material outside their relationship with you.
+
+ Conveying under any other circumstances is permitted solely under
+the conditions stated below. Sublicensing is not allowed; section 10
+makes it unnecessary.
+
+ 3. Protecting Users' Legal Rights From Anti-Circumvention Law.
+
+ No covered work shall be deemed part of an effective technological
+measure under any applicable law fulfilling obligations under article
+11 of the WIPO copyright treaty adopted on 20 December 1996, or
+similar laws prohibiting or restricting circumvention of such
+measures.
+
+ When you convey a covered work, you waive any legal power to forbid
+circumvention of technological measures to the extent such circumvention
+is effected by exercising rights under this License with respect to
+the covered work, and you disclaim any intention to limit operation or
+modification of the work as a means of enforcing, against the work's
+users, your or third parties' legal rights to forbid circumvention of
+technological measures.
+
+ 4. Conveying Verbatim Copies.
+
+ You may convey verbatim copies of the Program's source code as you
+receive it, in any medium, provided that you conspicuously and
+appropriately publish on each copy an appropriate copyright notice;
+keep intact all notices stating that this License and any
+non-permissive terms added in accord with section 7 apply to the code;
+keep intact all notices of the absence of any warranty; and give all
+recipients a copy of this License along with the Program.
+
+ You may charge any price or no price for each copy that you convey,
+and you may offer support or warranty protection for a fee.
+
+ 5. Conveying Modified Source Versions.
+
+ You may convey a work based on the Program, or the modifications to
+produce it from the Program, in the form of source code under the
+terms of section 4, provided that you also meet all of these conditions:
+
+ a) The work must carry prominent notices stating that you modified
+ it, and giving a relevant date.
+
+ b) The work must carry prominent notices stating that it is
+ released under this License and any conditions added under section
+ 7. This requirement modifies the requirement in section 4 to
+ "keep intact all notices".
+
+ c) You must license the entire work, as a whole, under this
+ License to anyone who comes into possession of a copy. This
+ License will therefore apply, along with any applicable section 7
+ additional terms, to the whole of the work, and all its parts,
+ regardless of how they are packaged. This License gives no
+ permission to license the work in any other way, but it does not
+ invalidate such permission if you have separately received it.
+
+ d) If the work has interactive user interfaces, each must display
+ Appropriate Legal Notices; however, if the Program has interactive
+ interfaces that do not display Appropriate Legal Notices, your
+ work need not make them do so.
+
+ A compilation of a covered work with other separate and independent
+works, which are not by their nature extensions of the covered work,
+and which are not combined with it such as to form a larger program,
+in or on a volume of a storage or distribution medium, is called an
+"aggregate" if the compilation and its resulting copyright are not
+used to limit the access or legal rights of the compilation's users
+beyond what the individual works permit. Inclusion of a covered work
+in an aggregate does not cause this License to apply to the other
+parts of the aggregate.
+
+ 6. Conveying Non-Source Forms.
+
+ You may convey a covered work in object code form under the terms
+of sections 4 and 5, provided that you also convey the
+machine-readable Corresponding Source under the terms of this License,
+in one of these ways:
+
+ a) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by the
+ Corresponding Source fixed on a durable physical medium
+ customarily used for software interchange.
+
+ b) Convey the object code in, or embodied in, a physical product
+ (including a physical distribution medium), accompanied by a
+ written offer, valid for at least three years and valid for as
+ long as you offer spare parts or customer support for that product
+ model, to give anyone who possesses the object code either (1) a
+ copy of the Corresponding Source for all the software in the
+ product that is covered by this License, on a durable physical
+ medium customarily used for software interchange, for a price no
+ more than your reasonable cost of physically performing this
+ conveying of source, or (2) access to copy the
+ Corresponding Source from a network server at no charge.
+
+ c) Convey individual copies of the object code with a copy of the
+ written offer to provide the Corresponding Source. This
+ alternative is allowed only occasionally and noncommercially, and
+ only if you received the object code with such an offer, in accord
+ with subsection 6b.
+
+ d) Convey the object code by offering access from a designated
+ place (gratis or for a charge), and offer equivalent access to the
+ Corresponding Source in the same way through the same place at no
+ further charge. You need not require recipients to copy the
+ Corresponding Source along with the object code. If the place to
+ copy the object code is a network server, the Corresponding Source
+ may be on a different server (operated by you or a third party)
+ that supports equivalent copying facilities, provided you maintain
+ clear directions next to the object code saying where to find the
+ Corresponding Source. Regardless of what server hosts the
+ Corresponding Source, you remain obligated to ensure that it is
+ available for as long as needed to satisfy these requirements.
+
+ e) Convey the object code using peer-to-peer transmission, provided
+ you inform other peers where the object code and Corresponding
+ Source of the work are being offered to the general public at no
+ charge under subsection 6d.
+
+ A separable portion of the object code, whose source code is excluded
+from the Corresponding Source as a System Library, need not be
+included in conveying the object code work.
+
+ A "User Product" is either (1) a "consumer product", which means any
+tangible personal property which is normally used for personal, family,
+or household purposes, or (2) anything designed or sold for incorporation
+into a dwelling. In determining whether a product is a consumer product,
+doubtful cases shall be resolved in favor of coverage. For a particular
+product received by a particular user, "normally used" refers to a
+typical or common use of that class of product, regardless of the status
+of the particular user or of the way in which the particular user
+actually uses, or expects or is expected to use, the product. A product
+is a consumer product regardless of whether the product has substantial
+commercial, industrial or non-consumer uses, unless such uses represent
+the only significant mode of use of the product.
+
+ "Installation Information" for a User Product means any methods,
+procedures, authorization keys, or other information required to install
+and execute modified versions of a covered work in that User Product from
+a modified version of its Corresponding Source. The information must
+suffice to ensure that the continued functioning of the modified object
+code is in no case prevented or interfered with solely because
+modification has been made.
+
+ If you convey an object code work under this section in, or with, or
+specifically for use in, a User Product, and the conveying occurs as
+part of a transaction in which the right of possession and use of the
+User Product is transferred to the recipient in perpetuity or for a
+fixed term (regardless of how the transaction is characterized), the
+Corresponding Source conveyed under this section must be accompanied
+by the Installation Information. But this requirement does not apply
+if neither you nor any third party retains the ability to install
+modified object code on the User Product (for example, the work has
+been installed in ROM).
+
+ The requirement to provide Installation Information does not include a
+requirement to continue to provide support service, warranty, or updates
+for a work that has been modified or installed by the recipient, or for
+the User Product in which it has been modified or installed. Access to a
+network may be denied when the modification itself materially and
+adversely affects the operation of the network or violates the rules and
+protocols for communication across the network.
+
+ Corresponding Source conveyed, and Installation Information provided,
+in accord with this section must be in a format that is publicly
+documented (and with an implementation available to the public in
+source code form), and must require no special password or key for
+unpacking, reading or copying.
+
+ 7. Additional Terms.
+
+ "Additional permissions" are terms that supplement the terms of this
+License by making exceptions from one or more of its conditions.
+Additional permissions that are applicable to the entire Program shall
+be treated as though they were included in this License, to the extent
+that they are valid under applicable law. If additional permissions
+apply only to part of the Program, that part may be used separately
+under those permissions, but the entire Program remains governed by
+this License without regard to the additional permissions.
+
+ When you convey a copy of a covered work, you may at your option
+remove any additional permissions from that copy, or from any part of
+it. (Additional permissions may be written to require their own
+removal in certain cases when you modify the work.) You may place
+additional permissions on material, added by you to a covered work,
+for which you have or can give appropriate copyright permission.
+
+ Notwithstanding any other provision of this License, for material you
+add to a covered work, you may (if authorized by the copyright holders of
+that material) supplement the terms of this License with terms:
+
+ a) Disclaiming warranty or limiting liability differently from the
+ terms of sections 15 and 16 of this License; or
+
+ b) Requiring preservation of specified reasonable legal notices or
+ author attributions in that material or in the Appropriate Legal
+ Notices displayed by works containing it; or
+
+ c) Prohibiting misrepresentation of the origin of that material, or
+ requiring that modified versions of such material be marked in
+ reasonable ways as different from the original version; or
+
+ d) Limiting the use for publicity purposes of names of licensors or
+ authors of the material; or
+
+ e) Declining to grant rights under trademark law for use of some
+ trade names, trademarks, or service marks; or
+
+ f) Requiring indemnification of licensors and authors of that
+ material by anyone who conveys the material (or modified versions of
+ it) with contractual assumptions of liability to the recipient, for
+ any liability that these contractual assumptions directly impose on
+ those licensors and authors.
+
+ All other non-permissive additional terms are considered "further
+restrictions" within the meaning of section 10. If the Program as you
+received it, or any part of it, contains a notice stating that it is
+governed by this License along with a term that is a further
+restriction, you may remove that term. If a license document contains
+a further restriction but permits relicensing or conveying under this
+License, you may add to a covered work material governed by the terms
+of that license document, provided that the further restriction does
+not survive such relicensing or conveying.
+
+ If you add terms to a covered work in accord with this section, you
+must place, in the relevant source files, a statement of the
+additional terms that apply to those files, or a notice indicating
+where to find the applicable terms.
+
+ Additional terms, permissive or non-permissive, may be stated in the
+form of a separately written license, or stated as exceptions;
+the above requirements apply either way.
+
+ 8. Termination.
+
+ You may not propagate or modify a covered work except as expressly
+provided under this License. Any attempt otherwise to propagate or
+modify it is void, and will automatically terminate your rights under
+this License (including any patent licenses granted under the third
+paragraph of section 11).
+
+ However, if you cease all violation of this License, then your
+license from a particular copyright holder is reinstated (a)
+provisionally, unless and until the copyright holder explicitly and
+finally terminates your license, and (b) permanently, if the copyright
+holder fails to notify you of the violation by some reasonable means
+prior to 60 days after the cessation.
+
+ Moreover, your license from a particular copyright holder is
+reinstated permanently if the copyright holder notifies you of the
+violation by some reasonable means, this is the first time you have
+received notice of violation of this License (for any work) from that
+copyright holder, and you cure the violation prior to 30 days after
+your receipt of the notice.
+
+ Termination of your rights under this section does not terminate the
+licenses of parties who have received copies or rights from you under
+this License. If your rights have been terminated and not permanently
+reinstated, you do not qualify to receive new licenses for the same
+material under section 10.
+
+ 9. Acceptance Not Required for Having Copies.
+
+ You are not required to accept this License in order to receive or
+run a copy of the Program. Ancillary propagation of a covered work
+occurring solely as a consequence of using peer-to-peer transmission
+to receive a copy likewise does not require acceptance. However,
+nothing other than this License grants you permission to propagate or
+modify any covered work. These actions infringe copyright if you do
+not accept this License. Therefore, by modifying or propagating a
+covered work, you indicate your acceptance of this License to do so.
+
+ 10. Automatic Licensing of Downstream Recipients.
+
+ Each time you convey a covered work, the recipient automatically
+receives a license from the original licensors, to run, modify and
+propagate that work, subject to this License. You are not responsible
+for enforcing compliance by third parties with this License.
+
+ An "entity transaction" is a transaction transferring control of an
+organization, or substantially all assets of one, or subdividing an
+organization, or merging organizations. If propagation of a covered
+work results from an entity transaction, each party to that
+transaction who receives a copy of the work also receives whatever
+licenses to the work the party's predecessor in interest had or could
+give under the previous paragraph, plus a right to possession of the
+Corresponding Source of the work from the predecessor in interest, if
+the predecessor has it or can get it with reasonable efforts.
+
+ You may not impose any further restrictions on the exercise of the
+rights granted or affirmed under this License. For example, you may
+not impose a license fee, royalty, or other charge for exercise of
+rights granted under this License, and you may not initiate litigation
+(including a cross-claim or counterclaim in a lawsuit) alleging that
+any patent claim is infringed by making, using, selling, offering for
+sale, or importing the Program or any portion of it.
+
+ 11. Patents.
+
+ A "contributor" is a copyright holder who authorizes use under this
+License of the Program or a work on which the Program is based. The
+work thus licensed is called the contributor's "contributor version".
+
+ A contributor's "essential patent claims" are all patent claims
+owned or controlled by the contributor, whether already acquired or
+hereafter acquired, that would be infringed by some manner, permitted
+by this License, of making, using, or selling its contributor version,
+but do not include claims that would be infringed only as a
+consequence of further modification of the contributor version. For
+purposes of this definition, "control" includes the right to grant
+patent sublicenses in a manner consistent with the requirements of
+this License.
+
+ Each contributor grants you a non-exclusive, worldwide, royalty-free
+patent license under the contributor's essential patent claims, to
+make, use, sell, offer for sale, import and otherwise run, modify and
+propagate the contents of its contributor version.
+
+ In the following three paragraphs, a "patent license" is any express
+agreement or commitment, however denominated, not to enforce a patent
+(such as an express permission to practice a patent or covenant not to
+sue for patent infringement). To "grant" such a patent license to a
+party means to make such an agreement or commitment not to enforce a
+patent against the party.
+
+ If you convey a covered work, knowingly relying on a patent license,
+and the Corresponding Source of the work is not available for anyone
+to copy, free of charge and under the terms of this License, through a
+publicly available network server or other readily accessible means,
+then you must either (1) cause the Corresponding Source to be so
+available, or (2) arrange to deprive yourself of the benefit of the
+patent license for this particular work, or (3) arrange, in a manner
+consistent with the requirements of this License, to extend the patent
+license to downstream recipients. "Knowingly relying" means you have
+actual knowledge that, but for the patent license, your conveying the
+covered work in a country, or your recipient's use of the covered work
+in a country, would infringe one or more identifiable patents in that
+country that you have reason to believe are valid.
+
+ If, pursuant to or in connection with a single transaction or
+arrangement, you convey, or propagate by procuring conveyance of, a
+covered work, and grant a patent license to some of the parties
+receiving the covered work authorizing them to use, propagate, modify
+or convey a specific copy of the covered work, then the patent license
+you grant is automatically extended to all recipients of the covered
+work and works based on it.
+
+ A patent license is "discriminatory" if it does not include within
+the scope of its coverage, prohibits the exercise of, or is
+conditioned on the non-exercise of one or more of the rights that are
+specifically granted under this License. You may not convey a covered
+work if you are a party to an arrangement with a third party that is
+in the business of distributing software, under which you make payment
+to the third party based on the extent of your activity of conveying
+the work, and under which the third party grants, to any of the
+parties who would receive the covered work from you, a discriminatory
+patent license (a) in connection with copies of the covered work
+conveyed by you (or copies made from those copies), or (b) primarily
+for and in connection with specific products or compilations that
+contain the covered work, unless you entered into that arrangement,
+or that patent license was granted, prior to 28 March 2007.
+
+ Nothing in this License shall be construed as excluding or limiting
+any implied license or other defenses to infringement that may
+otherwise be available to you under applicable patent law.
+
+ 12. No Surrender of Others' Freedom.
+
+ If conditions are imposed on you (whether by court order, agreement or
+otherwise) that contradict the conditions of this License, they do not
+excuse you from the conditions of this License. If you cannot convey a
+covered work so as to satisfy simultaneously your obligations under this
+License and any other pertinent obligations, then as a consequence you may
+not convey it at all. For example, if you agree to terms that obligate you
+to collect a royalty for further conveying from those to whom you convey
+the Program, the only way you could satisfy both those terms and this
+License would be to refrain entirely from conveying the Program.
+
+ 13. Use with the GNU Affero General Public License.
+
+ Notwithstanding any other provision of this License, you have
+permission to link or combine any covered work with a work licensed
+under version 3 of the GNU Affero General Public License into a single
+combined work, and to convey the resulting work. The terms of this
+License will continue to apply to the part which is the covered work,
+but the special requirements of the GNU Affero General Public License,
+section 13, concerning interaction through a network will apply to the
+combination as such.
+
+ 14. Revised Versions of this License.
+
+ The Free Software Foundation may publish revised and/or new versions of
+the GNU General Public License from time to time. Such new versions will
+be similar in spirit to the present version, but may differ in detail to
+address new problems or concerns.
+
+ Each version is given a distinguishing version number. If the
+Program specifies that a certain numbered version of the GNU General
+Public License "or any later version" applies to it, you have the
+option of following the terms and conditions either of that numbered
+version or of any later version published by the Free Software
+Foundation. If the Program does not specify a version number of the
+GNU General Public License, you may choose any version ever published
+by the Free Software Foundation.
+
+ If the Program specifies that a proxy can decide which future
+versions of the GNU General Public License can be used, that proxy's
+public statement of acceptance of a version permanently authorizes you
+to choose that version for the Program.
+
+ Later license versions may give you additional or different
+permissions. However, no additional obligations are imposed on any
+author or copyright holder as a result of your choosing to follow a
+later version.
+
+ 15. Disclaimer of Warranty.
+
+ THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY
+APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT
+HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY
+OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO,
+THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR
+PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM
+IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF
+ALL NECESSARY SERVICING, REPAIR OR CORRECTION.
+
+ 16. Limitation of Liability.
+
+ IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING
+WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS
+THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY
+GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE
+USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF
+DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD
+PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS),
+EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF
+SUCH DAMAGES.
+
+ 17. Interpretation of Sections 15 and 16.
+
+ If the disclaimer of warranty and limitation of liability provided
+above cannot be given local legal effect according to their terms,
+reviewing courts shall apply local law that most closely approximates
+an absolute waiver of all civil liability in connection with the
+Program, unless a warranty or assumption of liability accompanies a
+copy of the Program in return for a fee.
+
+ END OF TERMS AND CONDITIONS
+
+ How to Apply These Terms to Your New Programs
+
+ If you develop a new program, and you want it to be of the greatest
+possible use to the public, the best way to achieve this is to make it
+free software which everyone can redistribute and change under these terms.
+
+ To do so, attach the following notices to the program. It is safest
+to attach them to the start of each source file to most effectively
+state the exclusion of warranty; and each file should have at least
+the "copyright" line and a pointer to where the full notice is found.
+
+
+ Copyright (C)
+
+ This program is free software: you can redistribute it and/or modify
+ it under the terms of the GNU General Public License as published by
+ the Free Software Foundation, either version 3 of the License, or
+ (at your option) any later version.
+
+ This program is distributed in the hope that it will be useful,
+ but WITHOUT ANY WARRANTY; without even the implied warranty of
+ MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+ GNU General Public License for more details.
+
+ You should have received a copy of the GNU General Public License
+ along with this program. If not, see .
+
+Also add information on how to contact you by electronic and paper mail.
+
+ If the program does terminal interaction, make it output a short
+notice like this when it starts in an interactive mode:
+
+ Copyright (C)
+ This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'.
+ This is free software, and you are welcome to redistribute it
+ under certain conditions; type `show c' for details.
+
+The hypothetical commands `show w' and `show c' should show the appropriate
+parts of the General Public License. Of course, your program's commands
+might be different; for a GUI interface, you would use an "about box".
+
+ You should also get your employer (if you work as a programmer) or school,
+if any, to sign a "copyright disclaimer" for the program, if necessary.
+For more information on this, and how to apply and follow the GNU GPL, see
+.
+
+ The GNU General Public License does not permit incorporating your program
+into proprietary programs. If your program is a subroutine library, you
+may consider it more useful to permit linking proprietary applications with
+the library. If this is what you want to do, use the GNU Lesser General
+Public License instead of this License. But first, please read
+.
diff --git a/MX_post_processing_nodes.py b/MX_post_processing_nodes.py
new file mode 100644
index 0000000..d053817
--- /dev/null
+++ b/MX_post_processing_nodes.py
@@ -0,0 +1,1865 @@
+import torch
+from PIL import Image, ImageDraw, ImageFont
+import numpy as np
+import torch.nn.functional as F
+import cv2
+from PIL import Image, ImageEnhance
+from PIL import Image
+from typing import Literal, Any
+from torch import Tensor, dtype
+import torchvision.transforms.functional as D
+import math
+
+def radialspace_2D(
+ size: tuple[int] | tuple[int, int],
+ curvy: float = 1.0,
+ scale: float = 1.0,
+ mode: str = "square",
+ min_val: float = 0.0,
+ max_val: float = 1.0,
+ center: tuple[float, float] = (0.5, 0.5),
+ function: Any = None,
+ normalize: bool = True,
+ dtype: dtype = torch.float32
+ ) -> Tensor:
+
+ if isinstance(size, tuple):
+ if len(size) == 1:
+ width = height = size[0]
+ elif len(size) == 2:
+ width, height = size
+ else:
+ raise ValueError("Invalid size argument")
+ else:
+ raise TypeError("Size must be a tuple")
+
+ x = torch.linspace(0, 1, width)
+ y = torch.linspace(0, 1, height)
+
+ xx, yy = torch.meshgrid(x, y, indexing='ij')
+
+ if function is not None:
+ d = function(xx, yy)
+ elif mode == "square":
+ xx = (torch.abs(xx - center[0]) ** curvy)
+ yy = (torch.abs(yy - center[1]) ** curvy)
+ d = torch.max(xx, yy)
+ elif mode == "circle":
+ d = torch.sqrt((xx - center[0]) ** 2 + (yy - center[1]) ** 2)
+ d = (d ** curvy)
+ elif mode == "rectangle":
+ xx = (torch.abs(xx - center[0]) ** curvy)
+ yy = (torch.abs(yy - center[1]) ** curvy)
+ d = xx + yy
+ elif mode == "corners":
+ xx = (torch.abs(xx - center[0]) ** curvy)
+ yy = (torch.abs(yy - center[1]) ** curvy)
+ d = torch.min(xx, yy)
+ else:
+ raise ValueError("Not supported mode.")
+
+ if normalize:
+ d = d / d.max() * scale
+ d = torch.clamp(d, min_val, max_val)
+
+ return d.to(dtype)
+
+def cv2_layer(tensor: Tensor, function) -> Tensor:
+ """
+ This function applies a given function to each channel of an input tensor and returns the result as a PyTorch tensor.
+
+ :param tensor: A PyTorch tensor of shape (H, W, C) or (N, H, W, C), where C is the number of channels, H is the height, and W is the width of the image.
+ :param function: A function that takes a numpy array of shape (H, W, C) as input and returns a numpy array of the same shape.
+ :return: A PyTorch tensor of the same shape as the input tensor, where the given function has been applied to each channel of each image in the tensor.
+ """
+ shape_size = tensor.shape.__len__()
+
+ def produce(image):
+ channels = image[0, 0, :].shape[0]
+
+ rgb = image[:, :, 0:3].numpy()
+ result_rgb = function(rgb)
+
+ if channels <= 3:
+ return torch.from_numpy(result_rgb)
+ elif channels == 4:
+ alpha = image[:, :, 3:4].numpy()
+ result_alpha = function(alpha)[..., np.newaxis]
+ result_rgba = np.concatenate((result_rgb, result_alpha), axis=2)
+
+ return torch.from_numpy(result_rgba)
+
+ if shape_size == 3:
+ return torch.from_numpy(produce(tensor))
+ elif shape_size == 4:
+ return torch.stack([
+ produce(tensor[i]) for i in range(len(tensor))
+ ])
+ else:
+ raise ValueError("Incompatible tensor dimension.")
+
+def apply_to_batch(func):
+ def wrapper(self, image, *args, **kwargs):
+ images = []
+ for img in image:
+ images.append(func(self, img, *args, **kwargs))
+ batch_tensor = torch.cat(images, dim=0)
+ return (batch_tensor, )
+ return wrapper
+
+def gamma_correction_pil(image, gamma):
+ # Convert PIL Image to NumPy array
+ img_array = np.array(image)
+ # Normalization [0,255] -> [0,1]
+ img_array = img_array / 255.0
+ # Apply gamma correction
+ img_corrected = np.power(img_array, gamma)
+ # Convert corrected image back to original scale [0,1] -> [0,255]
+ img_corrected = np.uint8(img_corrected * 255)
+ # Convert NumPy array back to PIL Image
+ corrected_image = Image.fromarray(img_corrected)
+ return corrected_image
+
+
+def apply_hald_clut(lut_image, img):
+ hald_w, hald_h = lut_image.size
+ clut_size = int(round(pow(hald_w, 1/3)))
+ scale = (clut_size * clut_size - 1) / 255
+ img = np.asarray(img)
+
+ # Convert the HaldCLUT image to numpy array
+ hald_img_array = np.asarray(lut_image)
+
+ # If the HaldCLUT image is monochrome, duplicate its single channel to three
+ if len(hald_img_array.shape) == 2:
+ hald_img_array = np.stack([hald_img_array]*3, axis=-1)
+
+ hald_img_array = hald_img_array.reshape(clut_size ** 6, 3)
+
+ clut_r = np.rint(img[:, :, 0] * scale).astype(int)
+ clut_g = np.rint(img[:, :, 1] * scale).astype(int)
+ clut_b = np.rint(img[:, :, 2] * scale).astype(int)
+ filtered_image = np.zeros((img.shape))
+ filtered_image[:, :] = hald_img_array[clut_r + clut_size ** 2 * clut_g + clut_size ** 4 * clut_b]
+ filtered_image = Image.fromarray(filtered_image.astype('uint8'), 'RGB')
+ return filtered_image
+
+
+# Tensor to PIL
+def tensor2pil(image):
+ return Image.fromarray(np.clip(255. * image.cpu().numpy().squeeze(), 0, 255).astype(np.uint8))
+
+# PIL to Tensor
+def pil2tensor(image):
+ return torch.from_numpy(np.array(image).astype(np.float32) / 255.0).unsqueeze(0)
+
+def tensor2numpy(image):
+ # Convert tensor to numpy array and transpose dimensions from (C, H, W) to (H, W, C)
+ return (255.0 * image.cpu().numpy().squeeze().transpose(1, 2, 0)).astype(np.uint8)
+
+
+
+class Blend:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image1": ("IMAGE",),
+ "image2": ("IMAGE",),
+ "blend_factor": ("FLOAT", {
+ "default": 0.5,
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "blend_mode": ( [
+ "normal",
+ "multiply",
+ "screen",
+ "overlay",
+ "soft_light",
+ "hard_light",
+ "darken",
+ "lighten",
+ "color_dodge",
+ "color_burn",
+ "linear_dodge",
+ "linear_burn",
+ "linear_light",
+ "vivid_light",
+ "pin_light",
+ "difference",
+ "exclusion",
+ "divide"
+ ],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "blend_images"
+
+ CATEGORY = "postprocessing/Blends"
+
+ def blend_images(
+ self,
+ image1: torch.Tensor,
+ image2: torch.Tensor,
+ blend_factor: float,
+ blend_mode: str,
+ ):
+ if image1.shape != image2.shape:
+ image2 = self.crop_and_resize(image2, image1.shape)
+
+ blended_image = self.blend_mode(image1, image2, blend_mode)
+ blended_image = image1 * (1 - blend_factor) + blended_image * blend_factor
+ blended_image = torch.clamp(blended_image, 0, 1)
+ return (blended_image,)
+
+ def blend_mode(self, img1, img2, mode):
+ if mode == "normal":
+ return img2
+ elif mode == "multiply":
+ return img1 * img2
+ elif mode == "divide":
+ return img1 / img2
+ elif mode == "screen":
+ return 1 - (1 - img1) * (1 - img2)
+ elif mode == "overlay":
+ return torch.where(img1 <= 0.5, 2 * img1 * img2, 1 - 2 * (1 - img1) * (1 - img2))
+ elif mode == "soft_light":
+ return torch.where(img2 <= 0.5, img1 - (1 - 2 * img2) * img1 * (1 - img1), img1 + (2 * img2-1)*(self.g(img1)-img1))
+ elif mode == 'hard_light':
+ return torch.where(img2 < 0.5, 2 * img1 * img2, 1 - 2 * (1 - img1) * (1 - img2))
+ elif mode == 'darken':
+ return torch.min(img1, img2)
+ elif mode == 'lighten':
+ return torch.max(img1, img2)
+ elif mode == 'color_dodge':
+ return torch.where((img1 < 1), img1 / (1 - img2), torch.ones_like(img1))
+ elif mode == 'color_burn':
+ return torch.where(img2 > 0, (1 - ((1 - img1) / img2)), torch.zeros_like(img1))
+ elif mode == 'linear_dodge':
+ return img1 + img2
+ elif mode == 'linear_burn':
+ return img1 + img2 - 1
+ elif mode == 'linear_light':
+ return torch.where(img2 <= 0.5, img1 + 2 * (img2 - 0.5), img1 + 2 * img2 - 1)
+ elif mode == 'vivid_light':
+ return torch.where(img2 <= 0.5, (1 -(1-img1) / (2*img2)),img1/(2*(1-img2)))
+ elif mode == 'pin_light':
+ return torch.where(img2 < 0.5, torch.min(img1, 2*img2), torch.max(img1, 2*(img2-0.5)))
+ elif mode == 'difference':
+ return torch.abs(img1 - img2)
+ elif mode == 'exclusion':
+ return img1 + img2 - 2 * img1 * img2
+ else:
+ raise ValueError(f"Unsupported blend mode: {mode}")
+
+ def g(self, x):
+ return torch.where(x <= 0.25, ((16 * x - 12) * x + 4) * x, torch.sqrt(x))
+
+ def crop_and_resize(self, img: torch.Tensor, target_shape: tuple):
+ batch_size, img_h, img_w, img_c = img.shape
+ _, target_h, target_w, _ = target_shape
+ img_aspect_ratio = img_w / img_h
+ target_aspect_ratio = target_w / target_h
+
+ # Crop center of the image to the target aspect ratio
+ if img_aspect_ratio > target_aspect_ratio:
+ new_width = int(img_h * target_aspect_ratio)
+ left = (img_w - new_width) // 2
+ img = img[:, :, left:left + new_width, :]
+ else:
+ new_height = int(img_w / target_aspect_ratio)
+ top = (img_h - new_height) // 2
+ img = img[:, top:top + new_height, :, :]
+
+ # Resize to target size
+ img = img.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ img = F.interpolate(img, size=(target_h, target_w), mode='bilinear', align_corners=False)
+ img = img.permute(0, 2, 3, 1)
+
+ return img
+
+
+ batch_size, img_h, img_w, img_c = img.shape
+ _, target_h, target_w, _ = target_shape
+ img_aspect_ratio = img_w / img_h
+ target_aspect_ratio = target_w / target_h
+
+ # Crop center of the image to the target aspect ratio
+ if img_aspect_ratio > target_aspect_ratio:
+ new_width = int(img_h * target_aspect_ratio)
+ left = (img_w - new_width) // 2
+ img = img[:, :, left:left + new_width, :]
+ else:
+ new_height = int(img_w / target_aspect_ratio)
+ top = (img_h - new_height) // 2
+ img = img[:, top:top + new_height, :, :]
+
+ # Resize to target size
+ img = img.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ img = F.interpolate(img, size=(target_h, target_w), mode='bilinear', align_corners=False)
+ img = img.permute(0, 2, 3, 1)
+
+ return img
+
+
+class AlphaBlend:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image1": ("IMAGE",),
+ "image2": ("IMAGE",),
+ "image2_mask":("IMAGE",),
+ "blend_factor": ("FLOAT", {
+ "default": 1.0,
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "blend_mode": ( [
+ "normal",
+ "add",
+ ],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "blend_images"
+
+ CATEGORY = "postprocessing/Blends"
+
+ def blend_images(
+ self,
+ image1: torch.Tensor,
+ image2: torch.Tensor,
+ image2_mask: torch.Tensor,
+ blend_factor: float,
+ blend_mode: str,
+ ):
+
+ image1 = image1.to('cuda:0')
+ image2 = image2.to('cuda:0')
+ image2_mask = image2_mask.to('cuda:0')
+
+ if image1.shape != image2.shape:
+ image2 = self.crop_and_resize(image2, image1.shape)
+
+
+ if image1.shape != image2_mask.shape:
+ image2_mask = self.crop_and_resize(image2_mask, image1.shape)
+
+ blended_image = self.blend_mode(image1, image2, image2_mask, blend_mode)
+ blended_image = image1 * (1 - blend_factor) + blended_image * blend_factor
+ blended_image = torch.clamp(blended_image, 0, 1)
+ return (blended_image,)
+
+ def blend_mode(self, img1, img2,img2_mask, mode):
+ if mode == "normal":
+ return img1*img2_mask+img2*(1-img2_mask)
+ elif mode == "add":
+ return img1+img2*(1-img2_mask)
+ else:
+ raise ValueError(f"Unsupported blend mode: {mode}")
+
+ def g(self, x):
+ return torch.where(x <= 0.25, ((16 * x - 12) * x + 4) * x, torch.sqrt(x))
+
+ def crop_and_resize(self, img: torch.Tensor, target_shape: tuple):
+ batch_size, img_h, img_w, img_c = img.shape
+ _, target_h, target_w, _ = target_shape
+ img_aspect_ratio = img_w / img_h
+ target_aspect_ratio = target_w / target_h
+
+ # Crop center of the image to the target aspect ratio
+ if img_aspect_ratio > target_aspect_ratio:
+ new_width = int(img_h * target_aspect_ratio)
+ left = (img_w - new_width) // 2
+ img = img[:, :, left:left + new_width, :]
+ else:
+ new_height = int(img_w / target_aspect_ratio)
+ top = (img_h - new_height) // 2
+ img = img[:, top:top + new_height, :, :]
+
+ # Resize to target size
+ img = img.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ img = F.interpolate(img, size=(target_h, target_w), mode='bilinear', align_corners=False)
+ img = img.permute(0, 2, 3, 1)
+
+ return img
+
+
+ batch_size, img_h, img_w, img_c = img.shape
+ _, target_h, target_w, _ = target_shape
+ img_aspect_ratio = img_w / img_h
+ target_aspect_ratio = target_w / target_h
+
+ # Crop center of the image to the target aspect ratio
+ if img_aspect_ratio > target_aspect_ratio:
+ new_width = int(img_h * target_aspect_ratio)
+ left = (img_w - new_width) // 2
+ img = img[:, :, left:left + new_width, :]
+ else:
+ new_height = int(img_w / target_aspect_ratio)
+ top = (img_h - new_height) // 2
+ img = img[:, top:top + new_height, :, :]
+
+ # Resize to target size
+ img = img.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ img = F.interpolate(img, size=(target_h, target_w), mode='bilinear', align_corners=False)
+ img = img.permute(0, 2, 3, 1)
+
+ return img
+
+class Blur:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "blur_radius": ("INT", {
+ "default": 8,
+ "min": 1,
+ "max": 30,
+ "step": 1
+ }),
+ "sigma": ("FLOAT", {
+ "default": 1.0,
+ "min": 0.1,
+ "max": 10.0,
+ "step": 0.1
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "blur"
+
+ CATEGORY = "postprocessing/Filters"
+
+ def blur(self, image: torch.Tensor, blur_radius: int, sigma: float):
+ if blur_radius == 0:
+ return (image,)
+
+ batch_size, height, width, channels = image.shape
+
+ kernel_size = blur_radius * 2 + 1
+ kernel = gaussian_kernel(kernel_size, sigma).repeat(channels, 1, 1).unsqueeze(1)
+
+ image = image.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ blurred = F.conv2d(image, kernel, padding=kernel_size // 2, groups=channels)
+ blurred = blurred.permute(0, 2, 3, 1)
+
+ return (blurred,)
+
+class CannyEdgeMask:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "lower_threshold": ("FLOAT", {
+ "default": 0,
+ "min": 0,
+ "max": 1,
+ "step": 0.01
+ }),
+ "upper_threshold": ("FLOAT", {
+ "default": 1,
+ "min": 0,
+ "max": 1,
+ "step": 0.01
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "canny"
+
+ CATEGORY = "postprocessing/Masks"
+
+ def canny(self, image: torch.Tensor, lower_threshold: float, upper_threshold: float):
+ batch_size, height, width, _ = image.shape
+ result = torch.zeros(batch_size, height, width)
+ lower_threshold*=500
+ upper_threshold*=500
+
+ for b in range(batch_size):
+ tensor_image = image[b].numpy().copy()
+ gray_image = (cv2.cvtColor(tensor_image, cv2.COLOR_RGB2GRAY) * 255).astype(np.uint8)
+ canny = cv2.Canny(gray_image, lower_threshold, upper_threshold)
+ tensor = torch.from_numpy(canny)
+ result[b] = tensor
+
+ return (result,)
+
+class ChromaticAberration:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "red_shift": ("INT", {
+ "default": 0,
+ "min": -20,
+ "max": 20,
+ "step": 1
+ }),
+ "red_direction": (["horizontal", "vertical"],),
+ "green_shift": ("INT", {
+ "default": 0,
+ "min": -20,
+ "max": 20,
+ "step": 1
+ }),
+ "green_direction": (["horizontal", "vertical"],),
+ "blue_shift": ("INT", {
+ "default": 0,
+ "min": -20,
+ "max": 20,
+ "step": 1
+ }),
+ "blue_direction": (["horizontal", "vertical"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "chromatic_aberration"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def chromatic_aberration(self, image: torch.Tensor, red_shift: int, green_shift: int, blue_shift: int, red_direction: str, green_direction: str, blue_direction: str):
+ def get_shift(direction, shift):
+ shift = -shift if direction == 'vertical' else shift # invert vertical shift as otherwise positive actually shifts down
+ return (shift, 0) if direction == 'vertical' else (0, shift)
+
+ x = image.permute(0, 3, 1, 2)
+ shifts = [get_shift(direction, shift) for direction, shift in zip([red_direction, green_direction, blue_direction], [red_shift, green_shift, blue_shift])]
+ channels = [torch.roll(x[:, i, :, :], shifts=shifts[i], dims=(1, 2)) for i in range(3)]
+
+ output = torch.stack(channels, dim=1)
+ output = output.permute(0, 2, 3, 1)
+
+ return (output,)
+
+#https://github.com/hahnec/color-matcher/
+from color_matcher import ColorMatcher
+
+class ColorMatch:
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image_ref": ("IMAGE",),
+ "image_target": ("IMAGE",),
+ "method": (
+ [
+ 'mkl',
+ 'hm',
+ 'reinhard',
+ 'mvgd',
+ 'hm-mvgd-hm',
+ 'hm-mkl-hm',
+ ], {
+ "default": 'mkl'
+ }),
+
+ },
+ }
+
+ CATEGORY = "postprocessing/Color Adjustments"
+
+ RETURN_TYPES = ("IMAGE",)
+ RETURN_NAMES = ("image",)
+ FUNCTION = "colormatch"
+
+ def colormatch(self, image_ref, image_target, method):
+ cm = ColorMatcher()
+ image_ref = image_ref.cpu()
+ image_target = image_target.cpu()
+ batch_size = image_target.size(0)
+ out = []
+ images_target = image_target.squeeze()
+ images_ref = image_ref.squeeze()
+
+ image_ref_np = images_ref.numpy()
+ images_target_np = images_target.numpy()
+
+ if image_ref.size(0) > 1 and image_ref.size(0) != batch_size:
+ raise ValueError("ColorMatch: Use either single reference image or a matching batch of reference images.")
+
+ for i in range(batch_size):
+ image_target_np = images_target_np if batch_size == 1 else images_target[i].numpy()
+ image_ref_np_i = image_ref_np if image_ref.size(0) == 1 else images_ref[i].numpy()
+ try:
+ image_result = cm.transfer(src=image_target_np, ref=image_ref_np_i, method=method)
+ except BaseException as e:
+ print(f"Error occurred during transfer: {e}")
+ break
+ out.append(torch.from_numpy(image_result))
+ return (torch.stack(out, dim=0).to(torch.float32), )
+
+class ColorCorrect:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "temperature": ("FLOAT", {
+ "default": 0,
+ "min": -1,
+ "max": 1,
+ "step": 0.01
+ }),
+ "hue": ("FLOAT", {
+ "default": 0,
+ "min": 0,
+ "max": 360,
+ "step": 1
+ }),
+ "brightness": ("FLOAT", {
+ "default": 1,
+ "min": 0,
+ "max": 5,
+ "step": 0.01
+ }),
+ "contrast": ("FLOAT", {
+ "default": 1,
+ "min": 0,
+ "max": 5,
+ "step": 0.01
+ }),
+ "saturation": ("FLOAT", {
+ "default": 1,
+ "min": 0,
+ "max": 5,
+ "step": 0.01
+ }),
+ "gamma": ("FLOAT", {
+ "default": 1,
+ "min": 0.2,
+ "max": 2.2,
+ "step": 0.1
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "color_correct"
+
+ CATEGORY = "postprocessing/Color Adjustments"
+
+ def color_correct(self, image: torch.Tensor, temperature: float, hue: float, brightness: float, contrast: float, saturation: float, gamma: float):
+ batch_size, height, width, _ = image.shape
+ result = torch.zeros_like(image)
+
+ for b in range(batch_size):
+ tensor_image = image[b].numpy()
+
+ modified_image = Image.fromarray((tensor_image * 255).astype(np.uint8))
+
+ # brightness
+ modified_image = ImageEnhance.Brightness(modified_image).enhance(brightness)
+
+ # contrast
+ modified_image = ImageEnhance.Contrast(modified_image).enhance(contrast)
+ modified_image = np.array(modified_image).astype(np.float32)
+
+ # temperature
+ if temperature > 0:
+ modified_image[:, :, 0] *= 1 + temperature
+ modified_image[:, :, 1] *= 1 + temperature * 0.4
+ elif temperature < 0:
+ modified_image[:, :, 2] *= 1 - temperature
+ modified_image = np.clip(modified_image, 0, 255)/255
+
+ # gamma
+ modified_image = np.clip(np.power(modified_image, gamma), 0, 1)
+
+ # saturation
+ hls_img = cv2.cvtColor(modified_image, cv2.COLOR_RGB2HLS)
+ hls_img[:, :, 2] = np.clip(saturation*hls_img[:, :, 2], 0, 1)
+ modified_image = cv2.cvtColor(hls_img, cv2.COLOR_HLS2RGB) * 255
+
+ # hue
+ hsv_img = cv2.cvtColor(modified_image, cv2.COLOR_RGB2HSV)
+ hsv_img[:, :, 0] = (hsv_img[:, :, 0] + hue) % 360
+ modified_image = cv2.cvtColor(hsv_img, cv2.COLOR_HSV2RGB)
+
+ modified_image = modified_image.astype(np.uint8)
+ modified_image = modified_image / 255
+ modified_image = torch.from_numpy(modified_image).unsqueeze(0)
+ result[b] = modified_image
+
+ return (result, )
+
+class ColorTint:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "strength": ("FLOAT", {
+ "default": 1.0,
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.1
+ }),
+ "mode": (["custom","sepia", "red", "green", "blue", "cyan", "magenta", "yellow", "purple", "orange", "warm", "cool", "lime", "navy", "vintage", "rose", "teal", "maroon", "peach", "lavender", "olive"],),
+ "tint_color_hex": ("STRING", {"forceInput": True}),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "color_tint"
+
+ CATEGORY = "postprocessing/Color Adjustments"
+
+ # def hex_to_rgb(hex_color):
+ # hex_color = hex_color.lstrip('#') # Remove the '#' character, if present
+ # r = int(hex_color[0:2], 16)
+ # g = int(hex_color[2:4], 16)
+ # b = int(hex_color[4:6], 16)
+ # return (r, g, b)
+
+ def color_tint(self, image: torch.Tensor, strength: float, mode: str = "sepia" , tint_color_hex='#000000'):
+ if strength == 0:
+ return (image,)
+
+
+ tint_color_hex = tint_color_hex.lstrip('#')
+
+
+ color_rgb = [int(tint_color_hex[0:2], 16)/255,int(tint_color_hex[2:4], 16)/255,int(tint_color_hex[4:6], 16)/255]
+
+
+
+ sepia_weights = torch.tensor([0.2989, 0.5870, 0.1140]).view(1, 1, 1, 3).to(image.device)
+
+ mode_filters = {
+ "custom": torch.tensor([color_rgb[0], color_rgb[1], color_rgb[2]]),
+ "sepia": torch.tensor([1.0, 0.8, 0.6]),
+ "red": torch.tensor([1.0, 0.6, 0.6]),
+ "green": torch.tensor([0.6, 1.0, 0.6]),
+ "blue": torch.tensor([0.6, 0.8, 1.0]),
+ "cyan": torch.tensor([0.6, 1.0, 1.0]),
+ "magenta": torch.tensor([1.0, 0.6, 1.0]),
+ "yellow": torch.tensor([1.0, 1.0, 0.6]),
+ "purple": torch.tensor([0.8, 0.6, 1.0]),
+ "orange": torch.tensor([1.0, 0.7, 0.3]),
+ "warm": torch.tensor([1.0, 0.9, 0.7]),
+ "cool": torch.tensor([0.7, 0.9, 1.0]),
+ "lime": torch.tensor([0.7, 1.0, 0.3]),
+ "navy": torch.tensor([0.3, 0.4, 0.7]),
+ "vintage": torch.tensor([0.9, 0.85, 0.7]),
+ "rose": torch.tensor([1.0, 0.8, 0.9]),
+ "teal": torch.tensor([0.3, 0.8, 0.8]),
+ "maroon": torch.tensor([0.7, 0.3, 0.5]),
+ "peach": torch.tensor([1.0, 0.8, 0.6]),
+ "lavender": torch.tensor([0.8, 0.6, 1.0]),
+ "olive": torch.tensor([0.6, 0.7, 0.4]),
+ }
+
+ scale_filter = mode_filters[mode].view(1, 1, 1, 3).to(image.device)
+
+ grayscale = torch.sum(image * sepia_weights, dim=-1, keepdim=True)
+ tinted = grayscale * scale_filter
+
+ result = tinted * strength + image * (1 - strength)
+ return (result,)
+
+class FilmGrain:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "intensity": ("FLOAT", {
+ "default": 0.2,
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "scale": ("FLOAT", {
+ "default": 10,
+ "min": 1,
+ "max": 100,
+ "step": 1
+ }),
+ "temperature": ("FLOAT", {
+ "default": 0.0,
+ "min": -100,
+ "max": 100,
+ "step": 1
+ }),
+ "vignette": ("FLOAT", {
+ "default": 0.0,
+ "min": 0.0,
+ "max": 10.0,
+ "step": 1.0
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "film_grain"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def film_grain(self, image: torch.Tensor, intensity: float, scale: float, temperature: float, vignette: float):
+ batch_size, height, width, _ = image.shape
+ result = torch.zeros_like(image)
+
+ for b in range(batch_size):
+ tensor_image = image[b].numpy()
+
+ # Generate Perlin noise with shape (height, width) and scale
+ noise = self.generate_perlin_noise((height, width), scale)
+ noise = (noise - np.min(noise)) / (np.max(noise) - np.min(noise))
+
+ # Apply grain intensity
+ noise = (noise * 2 - 1) * intensity
+
+ # Blend the noise with the image
+ grain_image = np.clip(tensor_image + noise[:, :, np.newaxis], 0, 1)
+
+ # Apply temperature
+ grain_image = self.apply_temperature(grain_image, temperature)
+
+ # Apply vignette
+ grain_image = self.apply_vignette(grain_image, vignette)
+
+ tensor = torch.from_numpy(grain_image).unsqueeze(0)
+ result[b] = tensor
+
+ return (result,)
+
+ def generate_perlin_noise(self, shape, scale, octaves=4, persistence=0.5, lacunarity=2):
+ def smoothstep(t):
+ return t * t * (3.0 - 2.0 * t)
+
+ def lerp(t, a, b):
+ return a + t * (b - a)
+
+ def gradient(h, x, y):
+ vectors = np.array([[1, 1], [-1, 1], [1, -1], [-1, -1]])
+ g = vectors[h % 4]
+ return g[:, :, 0] * x + g[:, :, 1] * y
+
+ height, width = shape
+ noise = np.zeros(shape)
+
+ for octave in range(octaves):
+ octave_scale = scale * lacunarity ** octave
+ x = np.linspace(0, 1, width, endpoint=False)
+ y = np.linspace(0, 1, height, endpoint=False)
+ X, Y = np.meshgrid(x, y)
+ X, Y = X * octave_scale, Y * octave_scale
+
+ xi = X.astype(int)
+ yi = Y.astype(int)
+
+ xf = X - xi
+ yf = Y - yi
+
+ u = smoothstep(xf)
+ v = smoothstep(yf)
+
+ n00 = gradient(np.random.randint(0, 4, (height, width)), xf, yf)
+ n01 = gradient(np.random.randint(0, 4, (height, width)), xf, yf - 1)
+ n10 = gradient(np.random.randint(0, 4, (height, width)), xf - 1, yf)
+ n11 = gradient(np.random.randint(0, 4, (height, width)), xf - 1, yf - 1)
+
+ x1 = lerp(u, n00, n10)
+ x2 = lerp(u, n01, n11)
+ y1 = lerp(v, x1, x2)
+
+ noise += y1 * persistence ** octave
+
+ return noise / (1 - persistence ** octaves)
+
+ def apply_temperature(self, image, temperature):
+ if temperature == 0:
+ return image
+
+ temperature /= 100
+
+ new_image = image.copy()
+
+ if temperature > 0:
+ new_image[:, :, 0] *= 1 + temperature
+ new_image[:, :, 1] *= 1 + temperature * 0.4
+ else:
+ new_image[:, :, 2] *= 1 - temperature
+
+ return np.clip(new_image, 0, 1)
+
+ def apply_vignette(self, image, vignette_strength):
+ if vignette_strength == 0:
+ return image
+
+ height, width, _ = image.shape
+ x = np.linspace(-1, 1, width)
+ y = np.linspace(-1, 1, height)
+ X, Y = np.meshgrid(x, y)
+ radius = np.sqrt(X ** 2 + Y ** 2)
+
+ # Map vignette strength from 0-10 to 1.800-0.800
+ mapped_vignette_strength = 1.8 - (vignette_strength - 1) * 0.1
+ vignette = 1 - np.clip(radius / mapped_vignette_strength, 0, 1)
+
+ return np.clip(image * vignette[..., np.newaxis], 0, 1)
+
+class Glow:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "intensity": ("FLOAT", {
+ "default": 1.0,
+ "min": 0.0,
+ "max": 5.0,
+ "step": 0.01
+ }),
+ "blur_radius": ("INT", {
+ "default": 5,
+ "min": 1,
+ "max": 50,
+ "step": 1
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "apply_glow"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def apply_glow(self, image: torch.Tensor, intensity: float, blur_radius: int):
+ blurred_image = self.gaussian_blur(image, 2 * blur_radius + 1)
+ glowing_image = self.add_glow(image, blurred_image, intensity)
+ glowing_image = torch.clamp(glowing_image, 0, 1)
+ return (glowing_image,)
+
+ def gaussian_blur(self, image: torch.Tensor, kernel_size: int):
+ batch_size, height, width, channels = image.shape
+
+ sigma = (kernel_size - 1) / 6
+ kernel = gaussian_kernel(kernel_size, sigma).repeat(channels, 1, 1).unsqueeze(1)
+
+ image = image.permute(0, 3, 1, 2) # Torch wants (B, C, H, W) we use (B, H, W, C)
+ blurred = F.conv2d(image, kernel, padding=kernel_size // 2, groups=channels)
+ blurred = blurred.permute(0, 2, 3, 1)
+
+ return blurred
+
+ def add_glow(self, img, blurred_img, intensity):
+ return img + blurred_img * intensity
+
+class HSVThresholdMask:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "low_threshold": ("FLOAT", {
+ "default": 0.2,
+ "min": 0,
+ "max": 1,
+ "step": 0.1
+ }),
+ "high_threshold": ("FLOAT", {
+ "default": 0.7,
+ "min": 0,
+ "max": 1,
+ "step": 0.1
+ }),
+ "hsv_channel": (["hue", "saturation", "value"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "hsv_threshold"
+
+ CATEGORY = "postprocessing/Masks"
+
+ def hsv_threshold(self, image: torch.Tensor, low_threshold: float, high_threshold: float, hsv_channel: str):
+ batch_size, height, width, _ = image.shape
+ result = torch.zeros(batch_size, height, width)
+
+ if hsv_channel == "hue":
+ channel = 0
+ low_threshold, high_threshold = int(low_threshold * 180), int(high_threshold * 180)
+ elif hsv_channel == "saturation":
+ channel = 1
+ low_threshold, high_threshold = int(low_threshold * 255), int(high_threshold * 255)
+ elif hsv_channel == "value":
+ channel = 2
+ low_threshold, high_threshold = int(low_threshold * 255), int(high_threshold * 255)
+
+ for b in range(batch_size):
+ tensor_image = (image[b].numpy().copy() * 255).astype(np.uint8)
+ hsv_image = cv2.cvtColor(tensor_image, cv2.COLOR_RGB2HSV)
+
+ mask = cv2.inRange(hsv_image[:, :, channel], low_threshold, high_threshold)
+ tensor = torch.from_numpy(mask).float() / 255.
+ result[b] = tensor
+
+ return (result,)
+
+class KuwaharaBlur:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "blur_radius": ("INT", {
+ "default": 3,
+ "min": 0,
+ "max": 31,
+ "step": 1
+ }),
+ "method": (["mean", "gaussian"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "apply_kuwahara_filter"
+
+ CATEGORY = "postprocessing/Filters"
+
+ def apply_kuwahara_filter(self, image: np.ndarray, blur_radius: int, method: str):
+ if blur_radius == 0:
+ return (image,)
+
+ out = torch.zeros_like(image)
+ batch_size, height, width, channels = image.shape
+
+ for b in range(batch_size):
+ image = image[b].cpu().numpy() * 255.0
+ image = image.astype(np.uint8)
+
+ out[b] = torch.from_numpy(kuwahara(image, method=method, radius=blur_radius)) / 255.0
+
+ return (out,)
+
+def kuwahara(orig_img, method="mean", radius=3, sigma=None):
+ if method == "gaussian" and sigma is None:
+ sigma = -1
+
+ image = orig_img.astype(np.float32, copy=False)
+ avgs = np.empty((4, *image.shape), dtype=image.dtype)
+ stddevs = np.empty((4, *image.shape[:2]), dtype=image.dtype)
+ image_2d = cv2.cvtColor(orig_img, cv2.COLOR_BGR2GRAY).astype(image.dtype, copy=False)
+ avgs_2d = np.empty((4, *image.shape[:2]), dtype=image.dtype)
+
+ squared_img = image_2d ** 2
+
+ if method == "mean":
+ kxy = np.ones(radius + 1, dtype=image.dtype) / (radius + 1)
+ elif method == "gaussian":
+ kxy = cv2.getGaussianKernel(2 * radius + 1, sigma, ktype=cv2.CV_32F)
+ kxy /= kxy[radius:].sum()
+ klr = np.array([kxy[:radius+1], kxy[radius:]])
+ kindexes = [[1, 1], [1, 0], [0, 1], [0, 0]]
+
+ shift = [(0, 0), (0, radius), (radius, 0), (radius, radius)]
+
+ for k in range(4):
+ if method == "mean":
+ kx, ky = kxy, kxy
+ else:
+ kx, ky = klr[kindexes[k]]
+ cv2.sepFilter2D(image, -1, kx, ky, avgs[k], shift[k])
+ cv2.sepFilter2D(image_2d, -1, kx, ky, avgs_2d[k], shift[k])
+ cv2.sepFilter2D(squared_img, -1, kx, ky, stddevs[k], shift[k])
+ stddevs[k] = stddevs[k] - avgs_2d[k] ** 2
+
+ indices = np.argmin(stddevs, axis=0)
+ filtered = np.take_along_axis(avgs, indices[None,...,None], 0).reshape(image.shape)
+
+ return filtered.astype(orig_img.dtype)
+
+
+
+
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "mask": ("IMAGE",),
+ "direction": (["horizontal", "vertical"],),
+ "span_limit": ("INT", {
+ "default": None,
+ "min": 0,
+ "max": 100,
+ "step": 5
+ }),
+ "sort_by": (["hue", "saturation", "value"],),
+ "order": (["forward", "backward"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "sort_pixels"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def sort_pixels(self, image: torch.Tensor, mask: torch.Tensor, direction: str, span_limit: int, sort_by: str, order: str):
+ horizontal_sort = direction == "horizontal"
+ reverse_sorting = order == "backward"
+ sort_by = sort_by[0].upper()
+ span_limit = span_limit if span_limit > 0 else None
+
+ batch_size = image.shape[0]
+ result = torch.zeros_like(image)
+
+ for b in range(batch_size):
+ tensor_img = image[b].numpy()
+ tensor_mask = mask[b].numpy()
+ sorted_image = pixel_sort(tensor_img, tensor_mask, horizontal_sort, span_limit, sort_by, reverse_sorting)
+ result[b] = torch.from_numpy(sorted_image)
+
+ return (result,)
+
+class Pixelize:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "pixel_size": ("INT", {
+ "default": 8,
+ "min": 1,
+ "max": 256,
+ "step": 1
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "apply_pixelize"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def apply_pixelize(self, image: torch.Tensor, pixel_size: int):
+ pixelized_image = self.pixelize_image(image, pixel_size)
+ pixelized_image = torch.clamp(pixelized_image, 0, 1)
+ return (pixelized_image,)
+
+ def pixelize_image(self, image: torch.Tensor, pixel_size: int):
+ batch_size, height, width, channels = image.shape
+ new_height = height // pixel_size
+ new_width = width // pixel_size
+
+ image = image.permute(0, 3, 1, 2)
+ image = F.avg_pool2d(image, kernel_size=pixel_size, stride=pixel_size)
+ image = F.interpolate(image, size=(height, width), mode='nearest')
+ image = image.permute(0, 2, 3, 1)
+
+ return image
+
+class Quantize:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "colors": ("INT", {
+ "default": 16,
+ "min": 1,
+ "max": 256,
+ "step": 1
+ }),
+ "dither": (["none", "floyd-steinberg"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "quantize"
+
+ CATEGORY = "postprocessing/Color Adjustments"
+
+ def quantize(self, image: torch.Tensor, colors: int = 256, dither: str = "FLOYDSTEINBERG"):
+ batch_size, height, width, _ = image.shape
+ result = torch.zeros_like(image)
+
+ dither_option = Image.Dither.FLOYDSTEINBERG if dither == "floyd-steinberg" else Image.Dither.NONE
+
+ for b in range(batch_size):
+ tensor_image = image[b]
+ img = (tensor_image * 255).to(torch.uint8).numpy()
+ pil_image = Image.fromarray(img, mode='RGB')
+
+ palette = pil_image.quantize(colors=colors) # Required as described in https://github.com/python-pillow/Pillow/issues/5836
+ quantized_image = pil_image.quantize(colors=colors, palette=palette, dither=dither_option)
+
+ quantized_array = torch.tensor(np.array(quantized_image.convert("RGB"))).float() / 255
+ result[b] = quantized_array
+
+ return (result,)
+
+
+class SineWave:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "amplitude": ("FLOAT", {
+ "default": 20,
+ "min": 0,
+ "max": 150,
+ "step": 5
+ }),
+ "frequency": ("FLOAT", {
+ "default": 10,
+ "min": 0,
+ "max": 20,
+ "step": 1
+ }),
+ "direction": (["horizontal", "vertical"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "apply_sine_wave"
+
+ CATEGORY = "postprocessing/Effects"
+
+ def apply_sine_wave(self, image: torch.Tensor, amplitude: float, frequency: float, direction: str):
+ batch_size, height, width, channels = image.shape
+ result = torch.zeros_like(image)
+
+ for b in range(batch_size):
+ tensor_image = image[b]
+ result[b] = self.sine_wave_effect(tensor_image, amplitude, frequency, direction)
+
+ return (result,)
+
+ def sine_wave_effect(self, image: torch.Tensor, amplitude: float, frequency: float, direction: str):
+ height, width, _ = image.shape
+ shifted_image = torch.zeros_like(image)
+
+ for channel in range(3):
+ if direction == "horizontal":
+ for i in range(height):
+ offset = int(amplitude * np.sin(2 * torch.pi * i * frequency / height))
+ shifted_image[i, :, channel] = torch.roll(image[i, :, channel], offset)
+ elif direction == "vertical":
+ for j in range(width):
+ offset = int(amplitude * np.sin(2 * torch.pi * j * frequency / width))
+ shifted_image[:, j, channel] = torch.roll(image[:, j, channel], offset)
+
+ return shifted_image
+
+class Solarize:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(s):
+ return {
+ "required": {
+ "image": ("IMAGE",),
+ "threshold": ("FLOAT", {
+ "default": 0.5,
+ "min": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "solarize_image"
+
+ CATEGORY = "postprocessing/Color Adjustments"
+
+ def solarize_image(self, image: torch.Tensor, threshold: float):
+ solarized_image = torch.where(image > threshold, 1 - image, image)
+ solarized_image = torch.clamp(solarized_image, 0, 1)
+ return (solarized_image,)
+
+class LensBokeh:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "blades_shape": ("INT", {
+ "default": 5,
+ "min": 3,
+ }),
+ "blades_radius": ("INT", {
+ "default": 10,
+ "min": 1,
+ }),
+ "blades_rotation": ("FLOAT", {
+ "default": 0.0,
+ "min": 0.0,
+ "max": 360.0,
+ }),
+ "blur_size": ("INT", {
+ "default": 10,
+ "min": 1,
+ "step": 2
+ }),
+ "blur_type": (["bilateral", "stack", "none"],),
+ "method": (["dilate", "filter"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "node"
+ CATEGORY = "postprocessing/lens"
+
+ # noinspection PyUnresolvedReferences
+ def lens_blur(self, image, blades_shape, blades_radius, blades_rotation, method):
+ angles = np.linspace(0, 2 * np.pi, blades_shape + 1)[:-1] + blades_rotation * np.pi / 180
+ x = blades_radius * np.cos(angles) + blades_radius
+ y = blades_radius * np.sin(angles) + blades_radius
+ pts = np.stack([x, y], axis=1).astype(np.int32)
+
+ mask = np.zeros((blades_radius * 2 + 1, blades_radius * 2 + 1), np.uint8)
+ cv2.fillPoly(mask, [pts], 255)
+
+ gaussian_kernel = np.array([[0, -1, 0], [-1, 5, -1], [0, -1, 0]])
+
+ if method == "dilate":
+ kernel = cv2.filter2D(mask, -1, gaussian_kernel)
+ result = cv2.dilate(image, kernel)
+ elif method == "filter":
+ height, width = image.shape[:2]
+ dilate_size = min(height, width) // 512
+
+ if dilate_size > 0:
+ image = cv2.dilate(image, np.ones((dilate_size, dilate_size), np.uint8))
+
+ kernel = mask.astype(np.float32) / np.sum(mask)
+ kernel = cv2.filter2D(kernel, -1, gaussian_kernel)
+ result = cv2.filter2D(image, -1, kernel)
+ else:
+ raise ValueError("Unsupported method.")
+
+ return result
+
+ def node(self, images, blades_shape, blades_radius, blades_rotation, blur_size, blur_type, method):
+ tensor = images.clone().detach()
+ blur_size -= 1
+
+ if blur_type == "bilateral":
+ tensor = cv2_layer(tensor, lambda x: cv2.bilateralFilter(x, blur_size, -100, 100))
+ elif blur_type == "stack":
+ tensor = cv2_layer(tensor, lambda x: cv2.stackBlur(x, (blur_size, blur_size)))
+ elif blur_type == "none":
+ pass
+ else:
+ raise ValueError("Unsupported blur type.")
+
+ return (cv2_layer(tensor, lambda x: self.lens_blur(
+ x, blades_shape, blades_radius, blades_rotation, method
+ )),)
+
+class LensOpticAxis:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "lens_shape": (["circle", "square", "rectangle", "corners"],),
+ "lens_edge": (["around", "symmetric"],),
+ "lens_curvy": ("FLOAT", {
+ "default": 4.0,
+ "max": 15.0,
+ "step": 0.1,
+ }),
+ "lens_zoom": ("FLOAT", {
+ "default": 2.0,
+ "step": 0.1,
+ }),
+ "lens_aperture": ("FLOAT", {
+ "default": 0.5,
+ "max": 10.0,
+ "step": 0.1,
+ }),
+ "blur_intensity": ("INT", {
+ "default": 30,
+ "min": 2,
+ "step": 2
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE", "MASK")
+ FUNCTION = "node"
+ CATEGORY = "postprocessing/lens"
+
+ def node(self, images, lens_shape, lens_edge, lens_curvy, lens_zoom, lens_aperture, blur_intensity):
+ blur_intensity -= 1
+ lens_zoom += 1
+
+ height, width = images[0, :, :, 0].shape
+
+ if lens_edge == "around":
+ mask = radialspace_2D((height, width), lens_curvy, lens_zoom, lens_shape, 0.0, 1.0 + lens_curvy).unsqueeze(0).unsqueeze(3)
+ elif lens_edge == "symmetric":
+ if height != width:
+ new_height = new_width = max(height, width)
+ crop_top_bottom = (new_height - height) // 2
+ crop_left_right = (new_width - width) // 2
+
+ mask = radialspace_2D((new_height, new_width), lens_curvy, lens_zoom, lens_shape, 0.0, 1.0 + lens_curvy)[
+ crop_top_bottom:-crop_top_bottom or None,
+ crop_left_right:-crop_left_right or None
+ ].unsqueeze(0).unsqueeze(3)
+ else:
+ mask = radialspace_2D((height, width), lens_curvy, lens_zoom, lens_shape, 0.0, 1.0 + lens_curvy).unsqueeze(0).unsqueeze(3)
+ else:
+ raise ValueError("Not existing lens_edge parameter.")
+
+ center_x = width // 2
+ center_y = height // 2
+
+ y_v, x_v = torch.meshgrid(torch.arange(height), torch.arange(width), indexing='ij')
+
+ dx = x_v - center_x
+ dy = y_v - center_y
+
+ distance = torch.sqrt(dx ** 2 + dy ** 2)
+
+ map_x = x_v + mask[0, :, :, 0] * dx / distance * (-lens_aperture * 100)
+ map_y = y_v + mask[0, :, :, 0] * dy / distance * (-lens_aperture * 100)
+
+ map_x = map_x.to(torch.float32).numpy()
+ map_y = map_y.to(torch.float32).numpy()
+
+ shifted_images = cv2_layer(images, lambda x: cv2.remap(x, map_x, map_y, cv2.INTER_LINEAR))
+ shifted_mask = cv2_layer(mask, lambda x: cv2.remap(x, map_x, map_y, cv2.INTER_LINEAR))
+ edited_images = cv2_layer(shifted_images, lambda x: cv2.stackBlur(x, (blur_intensity, blur_intensity)))
+
+ mask = torch.clamp(mask, 0.0, 1.0)
+ shifted_mask = torch.clamp(shifted_mask, 0.0, 1.0)
+
+ result = shifted_images * (1 - mask) + edited_images * mask
+
+ return result, shifted_mask
+
+class LensZoomBurst:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "scale": ("FLOAT", {
+ "default": 1.5,
+ "min": 1.0,
+ "step": 0.01
+ }),
+ "samples": ("INT", {
+ "default": 100,
+ "min": 1,
+ }),
+ "position_x": ("FLOAT", {
+ "default": 0.5,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "position_y": ("FLOAT", {
+ "default": 0.5,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "rotation": ("FLOAT", {
+ "default": 0.0,
+ "min": 0.0,
+ "max": 360.0,
+ }),
+ "method": (["circle", "point"],),
+ "stabilization": (["true", "false"],),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE",)
+ FUNCTION = "node"
+ CATEGORY = "postprocessing/lens"
+
+ # noinspection PyUnresolvedReferences
+ def zoom_burst(
+ self,
+ image,
+ scale,
+ samples,
+ position,
+ rotation,
+ method,
+ stabilization,
+ ):
+ if scale < 1.0:
+ raise ValueError("Parameter scale can't be smaller then initial image size.")
+
+ h, w = image.shape[:2]
+
+ x = np.arange(w)
+ y = np.arange(h)
+
+ xx, yy = np.meshgrid(x, y)
+
+ cx = int(w * position[0])
+ cy = int(h * position[1])
+
+ if method == "circle":
+ d = np.sqrt((xx - cx) ** 2 + (yy - cy) ** 2)
+ max_d = np.sqrt((w / 2) ** 2 + (h / 2) ** 2)
+
+ map_x_up = (xx - d * (scale - 1.0) / max_d * (xx - cx) / samples).astype(np.float32)
+ map_y_up = (yy - d * (scale - 1.0) / max_d * (yy - cy) / samples).astype(np.float32)
+
+ map_x_down = (xx + d * (scale - 1.0) / max_d * (xx - cx) / samples).astype(np.float32)
+ map_y_down = (yy + d * (scale - 1.0) / max_d * (yy - cy) / samples).astype(np.float32)
+ elif method == "point":
+ map_x_up = (xx - (xx - cx) * (scale - 1.0) / samples).astype(np.float32)
+ map_y_up = (yy - (yy - cy) * (scale - 1.0) / samples).astype(np.float32)
+
+ map_x_down = (xx + (xx - cx) * (scale - 1.0) / samples).astype(np.float32)
+ map_y_down = (yy + (yy - cy) * (scale - 1.0) / samples).astype(np.float32)
+ else:
+ raise ValueError("Unsupported method.")
+
+ if rotation > 0.0:
+ angle_step = rotation / samples
+
+ rm_up = cv2.getRotationMatrix2D((cx, cy), angle_step, 1)
+ rm_down = cv2.getRotationMatrix2D((cx, cy), -angle_step, 1)
+ else:
+ vibration_angle = 1.0
+ vibration_step = vibration_angle / samples
+
+ rm_up_even = cv2.getRotationMatrix2D((cx, cy), vibration_step, 1)
+ rm_down_even = cv2.getRotationMatrix2D((cx, cy), -vibration_step, 1)
+
+ rm_up_odd = cv2.getRotationMatrix2D((cx, cy), -vibration_step, 1)
+ rm_down_odd = cv2.getRotationMatrix2D((cx, cy), vibration_step, 1)
+
+ for i in range(samples):
+ if stabilization:
+ tmp_up = cv2.remap(image, map_x_up, map_y_up, cv2.INTER_LINEAR)
+ tmp_down = cv2.remap(image, map_x_down, map_y_down, cv2.INTER_LINEAR, borderMode=cv2.BORDER_REFLECT)
+
+ if rotation > 0.0:
+ tmp_up = cv2.warpAffine(tmp_up, rm_up, (w, h), borderMode=cv2.BORDER_REFLECT)
+ tmp_down = cv2.warpAffine(tmp_down, rm_down, (w, h), borderMode=cv2.BORDER_REFLECT)
+ else:
+ if i % 2 == 0:
+ tmp_up = cv2.warpAffine(tmp_up, rm_up_even, (w, h), borderMode=cv2.BORDER_REFLECT)
+ tmp_down = cv2.warpAffine(tmp_down, rm_down_even, (w, h), borderMode=cv2.BORDER_REFLECT)
+ else:
+ tmp_up = cv2.warpAffine(tmp_up, rm_up_odd, (w, h), borderMode=cv2.BORDER_REFLECT)
+ tmp_down = cv2.warpAffine(tmp_down, rm_down_odd, (w, h), borderMode=cv2.BORDER_REFLECT)
+
+ image = cv2.addWeighted(tmp_up, 0.5, tmp_down, 0.5, 0)
+ else:
+ tmp = cv2.remap(image, map_x_up, map_y_up, cv2.INTER_LINEAR)
+
+ if rotation > 0.0:
+ rm = cv2.getRotationMatrix2D((cx, cy), angle_step, 1)
+ tmp = cv2.warpAffine(tmp, rm, (w, h), borderMode=cv2.BORDER_REFLECT)
+ else:
+ if i % 2 == 0:
+ tmp = cv2.warpAffine(tmp, rm_up_even, (w, h), borderMode=cv2.BORDER_REFLECT)
+ else:
+ tmp = cv2.warpAffine(tmp, rm_up_odd, (w, h), borderMode=cv2.BORDER_REFLECT)
+
+ image = cv2.addWeighted(tmp, 0.5, image, 0.5, 0)
+
+ return image
+
+ def node(self, images, scale, samples, position_x, position_y, rotation, method, stabilization):
+ tensor = images.clone().detach()
+
+ return (cv2_layer(tensor, lambda x: self.zoom_burst(
+ x, scale, samples, (position_x, position_y), rotation, method, True if stabilization == "true" else False
+ )),)
+
+class LUT:
+ @classmethod
+ def INPUT_TYPES(s):
+ return {"required": {"image": ("IMAGE",),
+ "lut_image": ("IMAGE",),
+ "gamma_correction": (['True','False'],)}}
+
+ RETURN_TYPES = ('IMAGE',)
+ FUNCTION = 'apply_lut'
+ CATEGORY = 'Processing/color adjust'
+ OUTPUT_NODE = True
+
+
+
+ @apply_to_batch
+ def apply_lut(self, image, lut_image, gamma_correction):
+ img = tensor2pil(image)
+ lut_image = tensor2pil(lut_image)
+
+ if gamma_correction == 'True':
+ corrected_img = gamma_correction_pil(img, 1.0/2.2)
+ else:
+ corrected_img = img
+ filtered_image = apply_hald_clut(lut_image, corrected_img).convert("RGB")
+ #return (pil2tensor(filtered_image), )
+ return pil2tensor(filtered_image)
+
+ @classmethod
+ def IS_CHANGED(self, hald_clut):
+ return (np.nan,)
+
+from typing import Literal, Any
+class Vignette:
+ def __init__(self):
+ pass
+
+ @classmethod
+ def INPUT_TYPES(cls):
+ return {
+ "required": {
+ "images": ("IMAGE",),
+ "lens_shape": (["circle", "rectangle"],),
+ "lens_edge": (["around", "symmetric"],),
+ "lens_curvy": ("FLOAT", {
+ "default": 3.0,
+ "max": 15.0,
+ "step": 0.1,
+ }),
+ "lens_zoom": ("FLOAT", {
+ "default": 0.0,
+ "step": 0.1,
+ }),
+ "brightness": ("FLOAT", {
+ "default": 0.0,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ "saturation": ("FLOAT", {
+ "default": 0.5,
+ "max": 1.0,
+ "step": 0.01
+ }),
+ },
+ }
+
+ RETURN_TYPES = ("IMAGE", "MASK")
+ FUNCTION = "node"
+ CATEGORY = "postprocessing/Effects"
+
+ def node(self, images, lens_shape, lens_edge, lens_curvy, lens_zoom, brightness, saturation):
+ tensor = images.clone().detach()
+
+ lens_zoom += 1
+
+ height, width = tensor[0, :, :, 0].shape
+
+ if lens_edge == "around":
+ mask = radialspace_2D((height, width), lens_curvy, lens_zoom, lens_shape).unsqueeze(0).unsqueeze(3)
+ elif lens_edge == "symmetric":
+ if height != width:
+ new_height = new_width = max(height, width)
+ crop_top_bottom = (new_height - height) // 2
+ crop_left_right = (new_width - width) // 2
+
+ mask = radialspace_2D((new_height, new_width), lens_curvy, lens_zoom, lens_shape)[
+ crop_top_bottom:-crop_top_bottom or None,
+ crop_left_right:-crop_left_right or None
+ ].unsqueeze(0).unsqueeze(3)
+ else:
+ mask = radialspace_2D((height, width), lens_curvy, lens_zoom, lens_shape).unsqueeze(0).unsqueeze(3)
+ else:
+ raise ValueError("Not existing lens_edge parameter.")
+
+ tensor = tensor.permute(0, 3, 1, 2)
+ tensor[:, 0:3, :, :] = D.adjust_brightness(tensor[:, 0:3, :, :], brightness)
+ tensor[:, 0:3, :, :] = D.adjust_saturation(tensor[:, 0:3, :, :], saturation)
+ tensor = tensor.permute(0, 2, 3, 1)
+
+ result = images * (1 - mask) + tensor * mask
+
+ mask = mask.squeeze()
+
+ return result, mask
+
+def gaussian_kernel(kernel_size: int, sigma: float):
+ x, y = torch.meshgrid(torch.linspace(-1, 1, kernel_size), torch.linspace(-1, 1, kernel_size), indexing="ij")
+ d = torch.sqrt(x * x + y * y)
+ g = torch.exp(-(d * d) / (2.0 * sigma * sigma))
+ return g / g.sum()
+
+def sort_span(span, sort_by, reverse_sorting):
+ if sort_by == 'H':
+ key = lambda x: x[1][0]
+ elif sort_by == 'S':
+ key = lambda x: x[1][1]
+ else:
+ key = lambda x: x[1][2]
+
+ span = sorted(span, key=key, reverse=reverse_sorting)
+ return [x[0] for x in span]
+
+
+def find_spans(mask, span_limit=None):
+ spans = []
+ start = None
+ for i, value in enumerate(mask):
+ if value == 0 and start is None:
+ start = i
+ if value == 1 and start is not None:
+ span_length = i - start
+ if span_limit is None or span_length <= span_limit:
+ spans.append((start, i))
+ start = None
+ if start is not None:
+ span_length = len(mask) - start
+ if span_limit is None or span_length <= span_limit:
+ spans.append((start, len(mask)))
+
+ return spans
+
+
+def pixel_sort(img, mask, horizontal_sort=False, span_limit=None, sort_by='H', reverse_sorting=False):
+ height, width, _ = img.shape
+ hsv_image = cv2.cvtColor(img, cv2.COLOR_RGB2HSV).astype(np.float32)
+ hsv_image[..., 0] /= 2.0 # Scale H channel to [0, 1] range
+
+ mask = np.where(mask > 0, 1, 0).astype(np.uint8)
+
+ # loop over the rows and replace contiguous bands of 1s
+ for i in range(height if horizontal_sort else width):
+ in_band = False
+ start = None
+ end = None
+ for j in range(width if horizontal_sort else height):
+ if (mask[i, j] if horizontal_sort else mask[j, i]) == 1:
+ if not in_band:
+ in_band = True
+ start = j
+ end = j
+ else:
+ if in_band:
+ for k in range(start+1, end):
+ if horizontal_sort:
+ mask[i, k] = 0
+ else:
+ mask[k, i] = 0
+ in_band = False
+
+ if in_band:
+ for k in range(start+1, end):
+ if horizontal_sort:
+ mask[i, k] = 0
+ else:
+ mask[k, i] = 0
+
+ sorted_image = np.zeros_like(img)
+ if horizontal_sort:
+ for y in range(height):
+ row_mask = mask[y]
+ spans = find_spans(row_mask, span_limit)
+ sorted_row = np.copy(img[y])
+ for start, end in spans:
+ span = [(img[y, x], hsv_image[y, x]) for x in range(start, end)]
+ sorted_span = sort_span(span, sort_by, reverse_sorting)
+ for i, pixel in enumerate(sorted_span):
+ sorted_row[start + i] = pixel
+ sorted_image[y] = sorted_row
+ else:
+ for x in range(width):
+ column_mask = mask[:, x]
+ spans = find_spans(column_mask, span_limit)
+ sorted_column = np.copy(img[:, x])
+ for start, end in spans:
+ span = [(img[y, x], hsv_image[y, x]) for y in range(start, end)]
+ sorted_span = sort_span(span, sort_by, reverse_sorting)
+ for i, pixel in enumerate(sorted_span):
+ sorted_column[start + i] = pixel
+ sorted_image[:, x] = sorted_column
+
+ return sorted_image
+
+NODE_CLASS_MAPPINGS = {
+ "MX_Blend": Blend,
+ "MX_AlphaBlend": AlphaBlend,
+ "MX_Blur": Blur,
+ "MX_Canny": CannyEdgeMask,
+ "MX_ColorMatch": ColorMatch,
+ "MX_ChromaticAberration": ChromaticAberration,
+ "MX_ColorCorrect": ColorCorrect,
+ "MX_ColorTint": ColorTint,
+ "MX_Noise": FilmGrain,
+ "MX_Glow": Glow,
+ "MX_HSVThresholdMask": HSVThresholdMask,
+ "MX_KuwaharaBlur(Cartoon)": KuwaharaBlur,
+ "MX_Mosaic": Pixelize,
+ "MX_Posterize": Quantize,
+ "MX_SineWave": SineWave,
+ "MX_Solarize": Solarize,
+ "MX_LensBokeh": LensBokeh,
+ "MX_LensOpticAxis": LensOpticAxis,
+ "MX_LensZoomBurst": LensZoomBurst,
+ "MX_LUT":LUT,
+ "MX_Vignette": Vignette,
+}
diff --git a/README.md b/README.md
index c8140ea..530e7c0 100644
--- a/README.md
+++ b/README.md
@@ -1 +1,60 @@
-# ComfyUI_MX_post_processing-nodes
\ No newline at end of file
+# ComfyUI-MX-post-processing-nodes
+
+A collection of post processing nodes for [ComfyUI](https://github.com/comfyanonymous/ComfyUI), dds image post-processing adjustment capabilities to the ComfyUI.
+
+## Example Image and Workflow
+
+
+
+
+
+
+
+
+---
+
+
+ $\Large\color{#00A7B5}\text{Expand Node List}$
+
+
+
+ - MX_Blend: Blends two images using arithmetic operations like add,multiply, overlay, darken,lighten.......
+ - MX_AlphaBlend: Blends two images alpha mask
+ - MX_Blur: Applies a Gaussian blur to the input image, softening the details
+ - MX_CannyEdgeMask: Creates a mask using canny edge detection
+ - MX_Chromatic Aberration: Shifts the color channels in an image, creating a glitch aesthetic
+ - MX_ColorCorrect: Adjusts the color balance, temperature, hue, brightness, contrast, saturation, and gamma of an image
+ - MX_ColorTint: Applies a customizable tint to the input image, with various color modes such as sepia, RGB, CMY and several composite colors
+ - MX_FilmGrain: Adds a film grain effect to the image, along with options to control the temperature, and vignetting.
+ - MX_Glow: Applies a blur with a specified radius and then blends it with the original image. Creates a nice glowing effect.
+ - MX_HSVThresholdMask: Creates a mask by thresholding HSV (hue, saturation, and value) channels
+ - MX_KuwaharaBlur:Applies an edge preserving blur, creating a more realistic blur than Gaussian.
+ - MX_PixelSort:Rearranges the pixels in the input image based on their values, and input mask. Creates a cool glitch like effect.
+ - MX_Pixelize: Applies a pixelization effect, simulating the reducing of resolution
+ - MX_Quantize: Set and dither the amount of colors in an image from 0-256, reducing color information
+ - MX_Sharpen: Enhances the details in an image by applying a sharpening filter
+ - MX_SineWave: Runs a sine wave through the image, making it appear squiggly
+ - MX_Solarize: Inverts image colors based on a threshold for a striking, high-contrast effect
+ - MX_Vignette: Applies a vignette effect, putting the corners of the image in shadow
+
+
+
+
+
+---
+
+## Install
+
+To install these nodes
+
+ - Navigate to your `/ComfyUI/custom_nodes/` folder
+ - Run `git clone https://github.com/Intersection98/MX-ComfyUI-post-processing-nodes/`
+ - pip install -r requirements.txt
+
+
+
+## Reference
+https://github.com/EllangoK/ComfyUI-post-processing-nodes/
+https://github.com/digitaljohn/comfyui-propost
+
+
diff --git a/__init__.py b/__init__.py
new file mode 100644
index 0000000..ea3abab
--- /dev/null
+++ b/__init__.py
@@ -0,0 +1,4 @@
+from .MX_post_processing_nodes import NODE_CLASS_MAPPINGS
+from .install import *
+
+__all__ = ["NODE_CLASS_MAPPINGS"]
\ No newline at end of file
diff --git a/combine_files.py b/combine_files.py
new file mode 100644
index 0000000..52e2e49
--- /dev/null
+++ b/combine_files.py
@@ -0,0 +1,126 @@
+from collections import OrderedDict
+from pathlib import Path
+import argparse
+
+ignore_dirs = ["old"]
+ignore_files = ["__init__.py", "combine_files.py", "test.py"]
+
+def get_python_files(path, recursive=False, args=None):
+ search_pattern = "**/*.py" if recursive else "*.py"
+
+ def should_include(file):
+ if file.is_file() and not args.output in str(file) and not file.name in ignore_files:
+ for ignore_dir in ignore_dirs:
+ if ignore_dir in str(file.parent):
+ return False
+ return True
+ return False
+
+ files = sorted([str(file) for file in Path(path).glob(search_pattern) if should_include(file)])
+ yield from files
+
+def parse_files(files):
+ imports = OrderedDict()
+ class_definitions = OrderedDict()
+ node_class_mappings = OrderedDict()
+ functions = OrderedDict()
+
+ for file in files:
+ # read file as lines
+ with open(file, "r") as f:
+ lines = f.readlines()
+
+ # remove comments
+ lines = [line for line in lines if not line.startswith("#")]
+
+ num_lines = len(lines)
+ i = 0
+ while i < num_lines:
+ line = lines[i]
+ if line.startswith("import") or line.startswith("from"):
+ imports[line.strip()] = None
+
+ elif line.startswith("class"):
+ class_info = line
+ j = i + 1
+ while not lines[j].startswith("NODE_CLASS_MAPPINGS"):
+ class_info += lines[j]
+ j += 1
+ class_definitions[class_info] = None
+ i = j - 1
+
+ elif line.startswith("NODE_CLASS_MAPPINGS"):
+ node_class_mappings[lines[i+1]] = None
+
+ elif line.startswith("def"):
+ function_info = line
+ j = i + 1
+ while j < num_lines and not lines[j].startswith("NODE_CLASS_MAPPINGS") and not lines[j].startswith("def"):
+ function_info += lines[j]
+ j += 1
+ functions[function_info] = None
+ i = j - 1
+
+ i += 1
+
+ return imports, class_definitions, node_class_mappings, functions
+
+def write_combined(imports: list[str], class_definitions: list[str], node_class_mappings: list[str], functions: list[str], output_file: str):
+ with open(output_file, "w") as f:
+ # write imports
+ for line in imports:
+ f.write(line + "\n")
+
+ # write 2 blank lines
+ f.write("\n\n")
+
+ # write class definitions
+ for line in class_definitions:
+ f.write(line)
+
+ # write functions
+ for line in functions:
+ f.write(line + "\n")
+
+ # write node class mappings
+ f.write("NODE_CLASS_MAPPINGS = {\n")
+ for line in node_class_mappings:
+ if not line.endswith(",\n"):
+ if line.endswith("\n"):
+ line = line[:-1] + ",\n"
+ else:
+ line += ",\n"
+
+ f.write(line)
+ f.write("}\n")
+
+
+def main():
+ parser = argparse.ArgumentParser(description="Collect unique imports from Python files")
+ parser.add_argument("--all", action="store_true", help="Include all Python files in the specified directory")
+ parser.add_argument("--files", nargs="+", help="Specify Python files to parse")
+ parser.add_argument("--folder", default=".", help="Specify a folder to search for files")
+ parser.add_argument("--output", default="MX_post_processing_nodes.py", help="Specify the output file name")
+ args = parser.parse_args()
+MX_
+ args.all = True
+
+ if args.all:
+ args.folder = "." if args.folder is None else args.folder
+ files = get_python_files(args.folder, recursive=True, args=args)
+ imports, class_definitions, node_class_mappings, functions = parse_files(files)
+ write_combined(imports, class_definitions, node_class_mappings, functions, args.output)
+ elif args.folder is not None:
+ files = get_python_files(args.folder, recursive=True, args=args)
+ imports, class_definitions, node_class_mappings, functions = parse_files(files)
+ write_combined(imports, class_definitions, node_class_mappings, functions, args.output)
+ else:
+ if args.files is None:
+ print("No files specified")
+ return
+
+ imports, class_definitions, node_class_mappings, functions = parse_files(args.files)
+ write_combined(imports, class_definitions, node_class_mappings, functions, args.output)
+
+if __name__ == "__main__":
+ main()
\ No newline at end of file
diff --git a/install.py b/install.py
new file mode 100644
index 0000000..471dd33
--- /dev/null
+++ b/install.py
@@ -0,0 +1,17 @@
+import sys
+import os.path
+import subprocess
+
+custom_nodes_path = os.path.dirname(os.path.abspath(__file__))
+
+def build_pip_install_cmds(args):
+ if "python_embeded" in sys.executable or "python_embedded" in sys.executable:
+ return [sys.executable, '-s', '-m', 'pip', 'install'] + args
+ else:
+ return [sys.executable, '-m', 'pip', 'install'] + args
+
+def ensure_package():
+ cmds = build_pip_install_cmds(['-r', 'requirements.txt'])
+ subprocess.run(cmds, cwd=custom_nodes_path)
+
+ensure_package()
\ No newline at end of file
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 0000000..c780026
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,3 @@
+numpy
+Pillow
+color-matcher