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