diff --git a/.gitignore b/.gitignore index a267f5811272c656f0b30bcd8a5ca8f434a84839..45c196d0fda499f4b5468b6a33e5c20732a57073 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,25 @@ /widgets/__pycache__ debug.log output/1.exe +/.trae +/__pycache__ +测试.jpg +指令合集.txt +Image_1767614094030.jpg +封面.png +打包/1.json +/output +色彩提取色卡_20260204_031736.png +PixPin_2026-02-04_04-08-56.png +/ui/widgets/__pycache__ +/ui/canvases/__pycache__ +/ui/__pycache__ +/dialogs/__pycache__ +/core/__pycache__ +/ui/interfaces/__pycache__ +/utils/__pycache__ +README - BetterGI StellTrack.md +/打包 +upx-5.1.0-win64.zip +/upx/upx-5.1.0-win64 +发行说明.md diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..dd255692296aa44600f285011bb5d832cbc929b6 --- /dev/null +++ b/LICENSE @@ -0,0 +1,639 @@ +取色卡(Color Card) - 许可证信息 + +================================================================================ +项目信息 +-------------------------------------------------------------------------------- +项目名称:Color Card +版权所有:© 2026 浮晓 HXiao Studio +开发者:青山公仔 +联系方式:hxiao_studio@163.com + +================================================================================ +作者声明 +-------------------------------------------------------------------------------- +虽然GPL v3允许商业使用,但作为作者,我恳请使用者遵守以下原则: + +- 非商业使用:请勿将此工具用于商业盈利目的 +- 共享改进:欢迎提交改进,但请保持开源精神 + +请注意,这只是作者的道德请求,法律上仍遵循GPL v3条款。 + +================================================================================ +⚠️ 重要提示 +-------------------------------------------------------------------------------- +本项目使用 PySide6 (LGPLv3),因此整个项目也必须以 GPLv3 开源! +本项目主要包含以下许可证: +1. 主项目许可证:GNU General Public License v3.0 (GPLv3) +2. 第三方库许可证:包含LGPL-3.0、GPL-3.0、MIT等多种开源许可证,详细的第三方库许可证信息请查看后续章节或应用程序的"关于"窗口 + +================================================================================ +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 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 . + +================================================================================ +第三方库许可证 +-------------------------------------------------------------------------------- +本项目使用了以下第三方库,每个库都有其自己的开源许可证: + +LGPL-3.0 +适用库: PySide6 + +GPL-3.0 +适用库: PySide6-Fluent-Widgets + +MIT License +适用库: Pillow + +Apache-2.0 +适用库: requests + +================================================================================ +使用说明 +-------------------------------------------------------------------------------- +许可证约束: +- 本项目整体受 GNU General Public License v3.0 约束 +- 使用本软件即表示您同意遵守所有相关许可证条款 + +根据 GPLv3 要求: +- 您可以自由使用、修改、分发本软件 +- 您必须以 GPLv3 许可证开源您的修改版本 +- 您必须提供源代码 +- 您不能将本软件用于闭源商业项目 + +如有疑问,请联系:hxiao_studio@163.com diff --git a/README.md b/README.md index ac748332835ba8173198923e361d98f58f4d52f7..a10ba4069df6368c1056505d515dfff02471a0f8 100644 --- a/README.md +++ b/README.md @@ -1,106 +1,379 @@ -# Color Extractor - 图片颜色提取器 +# 取色卡 (Color Card) -基于 PyQt6 开发的 GUI 程序,类似 Adobe Color 的色卡网站,用于从图片中提取颜色。 +## 项目概述 -## 功能特性 +**取色卡(Color Card)是一款专为摄影师和设计师打造的图片颜色分析工具**,用于从图片中提取颜色信息和分析明度分布。本工具基于 PySide6 和 PySide6-Fluent-Widgets 开发,提供了现代化的流畅界面,帮助用户快速获取图片的色彩数据。 -- **图片导入** - 支持 JPG、PNG、BMP、GIF 格式 -- **5个可拖动取色点** - 圆形设计,带白色边框,可自由拖动 -- **实时颜色提取** - 拖动时实时更新颜色 -- **HSB 和 LAB 显示** - 每个色卡显示对应的 HSB 和 LAB 值(双列布局) -- **高分辨率显示** - 使用原始图片实时缩放,保证显示清晰度 -- **简洁界面** - 无图片时显示提示,点击图片区域即可导入 +**开发理念**:在摄影后期处理中,色彩分析是一个重要的环节。取色卡旨在为摄影师提供一个简单、直观、专业的色彩提取工具,帮助分析图片的色调分布、提取关键色彩、理解明度构成。不同于通用的取色工具,取色卡专注于摄影场景的实际需求,提供专业级的色彩空间转换和明度分析功能。 -## 界面布局 +**开源协议**:本项目采用 **GNU General Public License v3.0 (GPL 3.0)** 开源协议,所有代码和文档均遵循该协议的条款和条件。 -``` -┌─────────────────────────────────────┐ -│ │ -│ 图片显示区域 │ -│ (5个可拖动圆形取色点) │ -│ │ -├─────────────────────────────────────┤ -│ ┌────┐ ┌────┐ ┌────┐ ┌────┐ ┌────┐│ -│ │颜色│ │颜色│ │颜色│ │颜色│ │颜色││ -│ │块 │ │块 │ │块 │ │块 │ │块 ││ -│ ├────┤ ├────┤ ├────┤ ├────┤ ├────┤│ -│ │H: │ │H: │ │H: │ │H: │ │H: ││ -│ │S: │ │S: │ │S: │ │S: │ │S: ││ -│ │B: │ │B: │ │B: │ │B: │ │B: ││ -│ │L: │ │L: │ │L: │ │L: │ │L: ││ -│ │a: │ │a: │ │a: │ │a: │ │a: ││ -│ │b: │ │b: │ │b: │ │b: │ │b: ││ -│ └────┘ └────┘ └────┘ └────┘ └────┘│ -└─────────────────────────────────────┘ -``` +**开源地址**: +- **主仓库(Gitee)**:https://gitee.com/qingshangongzai/color_card +- **镜像仓库(GitHub)**:https://github.com/qingshangongzai/Color_Card + +### 核心功能特色 + +- **可视化色彩提取**:通过直观的可拖动取色点,实时提取图片任意位置的颜色,支持5个取色点同时工作 +- **多色彩空间支持**:同时显示 HSB、LAB、HSL、CMYK、RGB 等多种色彩模式,满足不同场景的需求 +- **专业明度分析**:将图片按明度分为9个区域,提供直方图可视化,帮助理解图片的明度分布 +- **现代化界面**:基于 Fluent Design 设计语言,支持自动深色/浅色主题切换,提供流畅的用户体验 +- **高精度显示**:使用原始图片实时缩放,保证显示清晰度,取色点位置使用相对坐标系统,图片缩放时保持不变 +- **双面板同步**:色彩提取和明度分析面板数据实时同步,切换面板时自动更新 + +### 适用场景 + +- **摄影后期**:分析照片的色调分布,辅助调色决策,理解图片的色彩构成 +- **设计配色**:从参考图中提取配色方案,获取设计灵感 +- **色彩研究**:学习理解不同图片的色彩构成,提升色彩感知能力 + +--- + +## 安装指南 + +### 面向普通用户(使用安装包) + +1. 前往项目的 **Gitee 发布页** 下载最新的安装包(`.exe` 文件) +2. 运行下载的安装程序,跟随向导完成安装 +3. 从桌面快捷方式或开始菜单启动 "取色卡" + +### 面向开发者(从源码运行) + +#### 环境要求 -## 使用方法 +- **操作系统**:Windows 10/11 64位 +- **Python 版本**:Python 3.11 及以上 64位版本(推荐使用 3.14) +- **内存**:推荐 4GB 以上 +- **硬盘空间**:至少 100MB 可用空间 + +#### 依赖安装与运行 + +1. **克隆仓库**: + + ```bash + # 从 Gitee 克隆(国内推荐) + git clone https://gitee.com/qingshangongzai/color_card.git + + # 或从 GitHub 克隆 + git clone https://github.com/qingshangongzai/Color_Card.git + + cd color_card + ``` + +2. **创建虚拟环境(推荐)**: + + ```bash + python -m venv .venv + # 激活虚拟环境 + .\.venv\Scripts\activate # Windows + ``` + +3. **安装项目依赖**: + + ```bash + pip install -r requirements.txt + ``` + +4. **启动应用程序**: -1. 运行程序 ```bash python main.py ``` -2. 导入图片 - - 点击图片显示区域的"点击导入图片"提示 - - 或使用菜单栏 文件 → 打开图片 (Ctrl+O) +--- + +## 使用说明 + +### 基本操作 + +1. **启动应用**:运行 `main.py` 或 exe +2. **导入图片**:点击「打开图片」按钮或使用快捷键 `Ctrl+O`,支持拖拽导入 +3. **色彩提取**:在「色彩提取」标签页,拖动图片上的5个圆形取色点到任意位置,下方色卡会实时显示对应颜色的 HSB、LAB、HSL、CMYK、RGB 值 +4. **明度分析**:切换到「明度提取」标签页,查看图片的明度分布直方图,双击图片区域自动提取对应明度的像素 + +### 常用快捷键 + +| 快捷键 | 功能描述 | +|:---|:---| +| Ctrl + O | 打开图片 | +| Ctrl + Q | 退出程序 | + +### 功能详解 + +#### 色彩提取 + +- **5个可拖动取色点**:圆形设计,带白色边框,可自由拖动到图片任意位置,取色点编号显示 +- **实时颜色提取**:拖动时实时更新颜色值,响应迅速 +- **多色彩空间显示**:每个色卡显示对应的 HSB、LAB、HSL、CMYK、RGB 值,支持一键复制 +- **高分辨率显示**:使用原始图片实时缩放,保证显示清晰度,避免多次缩放导致的画质损失 +- **相对坐标系统**:取色点位置使用相对坐标(0.0-1.0)存储,图片缩放时保持不变 + +#### 明度提取 + +- **9个明度区域**:将图片按明度分为9个区域(极暗、暗、中暗、次中暗、中灰、次中亮、中亮、亮、极亮) +- **明度直方图**:实时显示图片明度分布直方图,支持区域选择和高亮 +- **区域高亮显示**:选中区域在直方图上高亮显示,方便查看特定明度范围的像素分布 +- **双击提取**:双击图片任意区域,自动提取该明度对应的像素,并在色卡中显示 -3. 提取颜色 - - 拖动图片上的5个圆形取色点到任意位置 - - 下方色卡会实时显示对应颜色的 HSB 和 LAB 值 +--- -## 文件结构 +## 技术架构与设计理念 + +### 核心技术栈 + +| 技术/框架 | 用途 | 版本 | +|:---|:---|:---:| +| Python | 主要开发语言 | 3.11+ | +| PySide6 | GUI 应用程序框架 | 6.0+ | +| PySide6-Fluent-Widgets | 现代化 UI 组件库 | 1.0+ | +| Pillow | 图像处理 | 9.0+ | + +### 架构设计原则 + +1. **模块化设计**:将功能划分为独立的模块,每个模块职责明确 +2. **关注点分离**:UI 组件、业务逻辑和数据处理层分离,便于维护和测试 +3. **基类抽象**:提取公共基类(BaseCanvas、BaseCard、BaseHistogram),消除代码重复,提高代码复用性 +4. **扁平化结构**:采用2级目录结构,避免过度嵌套,提高代码可维护性 +5. **相对坐标系统**:取色点位置使用相对坐标存储,图片缩放时自动调整,保持位置不变 + +### 项目结构 ``` -. -├── main.py # 程序入口 -├── color_utils.py # 颜色转换工具(RGB ↔ HSB/LAB) +color_card/ +├── main.py # 应用程序主入口 +├── version.py # 版本管理器模块 +├── requirements.txt # 项目依赖列表 ├── README.md # 项目说明文档 -└── widgets/ +├── LICENSE # 开源许可证文件 +├── 开发规范.md # 开发规范文档 +├── core/ # 核心功能模块目录 +│ ├── __init__.py +│ ├── color.py # 颜色处理模块(颜色转换、明度计算、直方图计算) +│ └── config.py # 配置管理模块 +├── ui/ # UI模块目录(扁平化结构) +│ ├── __init__.py # 统一导出接口 +│ ├── main_window.py # 主窗口类 +│ ├── canvases.py # 画布模块(BaseCanvas、ImageCanvas、LuminanceCanvas) +│ ├── cards.py # 卡片组件模块(ColorCard、LuminanceCard) +│ ├── histograms.py # 直方图组件模块(LuminanceHistogramWidget、RGBHistogramWidget) +│ ├── color_picker.py # 颜色选择器模块 +│ ├── color_wheel.py # 颜色轮模块 +│ ├── zoom_viewer.py # 缩放查看器模块 +│ └── interfaces.py # 界面面板模块 +├── dialogs/ # 对话框模块目录 +│ ├── __init__.py +│ ├── about_dialog.py # 关于对话框 +│ └── update_dialog.py # 更新检查对话框 +└── utils/ # 工具函数模块目录 ├── __init__.py - ├── main_window.py # 主窗口 - ├── image_canvas.py # 图片显示画布(含取色点管理) - ├── color_picker.py # 可拖动取色点组件 - └── color_card.py # 色卡显示面板 + ├── icon.py # 图标工具模块 + └── platform.py # 平台相关工具模块 +``` + +### 核心模块详解 + +#### 1. 颜色处理模块 (core/color.py) + +负责所有与颜色相关的计算和转换: + +- **颜色空间转换**:RGB ↔ HSB、RGB ↔ LAB、RGB ↔ HSL、RGB ↔ CMYK,支持多种色彩空间 +- **明度计算**:使用 Rec. 709 标准计算亮度值,包含 sRGB Gamma 校正,与 Lightroom、Photoshop 等专业软件使用相同标准 +- **直方图计算**:计算图片的明度分布和 RGB 通道分布,支持采样优化 + +#### 2. 画布模块 (ui/canvases.py) + +提供图片显示和交互功能: + +- **BaseCanvas**:画布基类,提供图片加载、显示、坐标转换等通用功能 + - 图片加载与显示(保持比例居中) + - 拖拽打开图片 + - 右键菜单框架 + - 坐标转换(画布坐标 ↔ 图片坐标 ↔ 相对坐标) +- **ImageCanvas**:图片画布,支持5个可拖动取色点 + - 取色点管理(添加、删除、拖动) + - 相对坐标系统(取色点位置在图片缩放时保持不变) + - 实时颜色提取和信号发射 +- **LuminanceCanvas**:明度画布,支持9级明度分区显示 + - 明度分区覆盖层绘制 + - 区域选择和高亮显示 + - 双击提取像素功能 + +#### 3. 卡片模块 (ui/cards.py) + +提供颜色信息展示功能: + +- **BaseCard / BaseCardPanel**:卡片基类,提供统一的卡片接口 + - setup_ui:设置界面 + - clear:清空卡片 +- **ColorCard / ColorCardPanel**:色彩卡片,显示多种色彩空间值 + - 支持 HSB、LAB、HSL、CMYK、RGB 显示 + - 一键复制颜色值 +- **LuminanceCard / LuminanceCardPanel**:明度卡片,显示明度区域信息 + - 显示区域名称和明度范围 + - 显示像素数量 + +#### 4. 直方图模块 (ui/histograms.py) + +提供数据可视化功能: + +- **BaseHistogram**:直方图基类,提供通用的绘制框架 + - 数据管理(set_data, clear) + - 绘制框架(paintEvent) + - 辅助方法(绘制基线、最大值标签) +- **LuminanceHistogramWidget**:明度直方图 + - 9个明度区域显示 + - 区域选择和高亮 + - 鼠标交互 +- **RGBHistogramWidget**:RGB通道直方图 + - R、G、B 三通道显示 + - 叠例显示 + +--- + +## 技术亮点与创新 + +**取色卡 (Color Card)** 在技术上具有多项亮点和创新,体现了现代 GUI 应用开发的良好实践: + +### 1. 现代化 UI 设计 + +- **简约设计风格**:采用极简的设计语言,减少不必要的元素,突出核心功能 +- **高 DPI 支持**:自动适配不同屏幕分辨率和缩放比例,保证显示清晰 +- **平滑动画**:添加了窗口过渡、控件交互等平滑动画效果,提升用户体验 +- **响应式设计**:适配不同屏幕尺寸和布局,保证内容完整显示 +- **主题切换**:支持浅色和深色两种主题模式,深色模式使用纯黑色背景,减少眼部疲劳 + +### 2. 高效的事件处理机制 + +- **多线程设计**:耗时操作(如直方图计算)放在后台线程执行,保证 UI 响应流畅 +- **批量更新优化**:使用 setUpdatesEnabled(False/True) 包裹批量更新操作,避免频繁重绘 +- **相对坐标系统**:取色点位置使用相对坐标存储,图片缩放时自动调整,保持位置不变 +- **采样优化**:直方图计算使用采样策略,避免处理所有像素,提高计算效率 + +### 3. 专业的色彩处理 + +- **多种色彩空间**:支持 HSB、LAB、HSL、CMYK、RGB 等多种色彩空间,满足不同场景需求 +- **Rec. 709 标准**:明度计算使用 Rec. 709 标准,包含 sRGB Gamma 校正,与专业软件保持一致 +- **LAB 颜色空间**:支持 LAB 颜色空间,适合颜色差异计算和感知均匀性分析 +- **实时转换**:所有色彩空间转换实时计算,拖动取色点时立即更新 + +### 4. 完善的调试和监控机制 + +- **详细日志**:提供全面的调试信息,便于调试和问题排查 +- **异常处理**:全局异常捕获,防止应用程序意外崩溃 +- **配置管理**:支持窗口状态记忆和设置持久化 + +### 5. 模块化架构设计 + +- **清晰的模块划分**:功能模块化,便于维护和扩展 +- **基类抽象**:提取公共基类(BaseCanvas、BaseCard、BaseHistogram),消除代码重复 +- **扁平化结构**:采用2级目录结构,避免过度嵌套,提高代码可维护性 +- **高内聚实现**:每个模块专注于特定功能,实现高内聚 +- **可扩展性**:设计时考虑了未来功能扩展,便于添加新特性 + +--- + +## 开发规范 + +本项目遵循严格的开发规范,确保代码质量和可维护性。 + +### 代码风格 + +- 遵循 **PEP 8** 代码风格规范 +- 使用 4 个空格缩进 +- 行长度限制在 100 字符以内 + +### 命名规范 + +| 类型 | 规范 | 示例 | +|:---|:---|:---:| +| 类名 | 驼峰命名法 | `ColorPicker`, `ImageCanvas` | +| 函数/方法 | 小写+下划线 | `extract_color()` | +| 变量 | 小写+下划线 | `picker_positions` | +| 常量 | 大写+下划线 | `PICKER_RADIUS = 12` | +| 私有属性 | 单下划线前缀 | `_dragging` | + +### 导入规范 + +导入顺序:**标准库 → 第三方库 → 项目模块** + +```python +# 标准库导入 +import sys +import math +from pathlib import Path + +# 第三方库导入 +from PySide6.QtWidgets import QApplication, QMainWindow +from PySide6.QtCore import Qt, Signal +from qfluentwidgets import FluentWindow + +# 项目模块导入 +from core import get_color_info, get_config_manager +from ui import MainWindow ``` -## 技术细节 +详细的开发规范请参考 [开发规范.md](./开发规范.md)。 + +--- + +## 贡献指南 + +我们欢迎并感谢所有社区成员对 Color Card 的贡献。 + +### 提交 Issue + +如果你发现了 Bug,或有新的功能建议,请先在 Gitee 的 Issues 页面搜索是否已有相关问题。如果没有,请创建新的 Issue,并尽量详细地描述问题或建议。 + +### 代码贡献流程 + +1. Fork 本项目的 Gitee 主仓库或 GitHub 镜像仓库 +2. 创建你的特性分支:`git checkout -b feature/你的功能名称` +3. 提交你的更改:`git commit -m '[类型] 添加了某个功能'` +4. 将分支推送到你的 Fork:`git push origin feature/你的功能名称` +5. 在 Gitee 主仓库上对该分支创建一个 Pull Request(推荐) + +### 遵循开发规范 + +所有贡献的代码必须严格遵循项目已有的开发规范: + +- [开发规范.md](./开发规范.md) - 涵盖代码组织、样式、命名等全方位规范 + +--- + +## 许可证信息 + +### 主项目许可证 + +Color Card 采用 **GNU General Public License v3.0 (GPL 3.0)** 许可证发布。这意味着您可以自由地使用、修改和分发本软件,但如果您分发修改后的版本,也必须采用相同的 GPL 3.0 许可证开源您的修改。 -### 颜色空间转换 +### 许可证文件 -- **HSB** (Hue, Saturation, Brightness) - - H: 色相 (0-360°) - - S: 饱和度 (0-100%) - - B: 亮度 (0-100%) +- **项目完整许可证信息**:[LICENSE](./LICENSE) +- **GPL 3.0 官方文本**:[https://www.gnu.org/licenses/gpl-3.0.html](https://www.gnu.org/licenses/gpl-3.0.html) -- **LAB** (Lightness, a, b) - - L: 明度 - - a: 绿-红轴 - - b: 蓝-黄轴 +### 第三方库许可证 -### 图片显示优化 +本项目使用了以下第三方库: -- 保留原始高分辨率图片 -- 绘制时实时缩放显示 -- 取色时映射到原始图片坐标 -- 避免多次缩放导致的画质损失 +| 库 | 许可证 | +|:---|:---:| +| PySide6 | LGPL-3.0 | +| PySide6-Fluent-Widgets | GPL-3.0 | +| Pillow | MIT License | -## 依赖 +--- -- Python 3.x -- PyQt6 +## 联系方式 -## 快捷键 +- **主仓库(Gitee)**:[https://gitee.com/qingshangongzai/color_card](https://gitee.com/qingshangongzai/color_card) +- **镜像仓库(GitHub)**:[https://github.com/qingshangongzai/Color_Card](https://github.com/qingshangongzai/Color_Card) +- **联系邮箱**:[hxiao_studio@163.com](mailto:hxiao_studio@163.com) -- `Ctrl + O` - 打开图片 -- `Ctrl + Q` - 退出程序 +--- -## 版本历史 +**免责声明**:Color Card 仅供学习和研究使用。开发者不对因使用本工具导致的任何后果负责,请谨慎使用。 -### 当前版本 +--- -- 基础功能实现 -- 5个可拖动取色点 -- HSB/LAB 双列显示 -- 高分辨率图片显示优化 -- 简洁的导入界面 +**取色卡 (Color Card)** - 为摄影师和设计师打造的专业色彩分析工具 +Copyright © 2026 浮晓 HXiao Studio diff --git a/color_utils.py b/color_utils.py deleted file mode 100644 index 5a45f70fd93f13dad8e179d5c5ddf8e79f58ba28..0000000000000000000000000000000000000000 --- a/color_utils.py +++ /dev/null @@ -1,52 +0,0 @@ -import colorsys -import math - - -def rgb_to_hsb(r, g, b): - """将RGB转换为HSB (Hue, Saturation, Brightness)""" - r, g, b = r / 255.0, g / 255.0, b / 255.0 - h, s, v = colorsys.rgb_to_hsv(r, g, b) - return h * 360, s * 100, v * 100 - - -def rgb_to_lab(r, g, b): - """将RGB转换为LAB颜色空间""" - # 首先转换为XYZ - r, g, b = r / 255.0, g / 255.0, b / 255.0 - - # 应用gamma校正 - r = r ** 2.2 if r > 0.04045 else r / 12.92 - g = g ** 2.2 if g > 0.04045 else g / 12.92 - b = b ** 2.2 if b > 0.04045 else b / 12.92 - - # 转换为XYZ - x = r * 0.4124564 + g * 0.3575761 + b * 0.1804375 - y = r * 0.2126729 + g * 0.7151522 + b * 0.0721750 - z = r * 0.0193339 + g * 0.1191920 + b * 0.9503041 - - # 参考白点D65 - x_ref, y_ref, z_ref = 0.95047, 1.00000, 1.08883 - - x, y, z = x / x_ref, y / y_ref, z / z_ref - - # 转换为LAB - def f(t): - return t ** (1/3) if t > 0.008856 else 7.787 * t + 16/116 - - l = 116 * f(y) - 16 - a = 500 * (f(x) - f(y)) - b_val = 200 * (f(y) - f(z)) - - return l, a, b_val - - -def get_color_info(r, g, b): - """获取颜色的完整信息""" - h, s, b_val = rgb_to_hsb(r, g, b) - l, a, b_lab = rgb_to_lab(r, g, b) - - return { - 'rgb': (r, g, b), - 'hsb': (round(h), round(s), round(b_val)), - 'lab': (round(l), round(a), round(b_lab)) - } diff --git a/core/__init__.py b/core/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..658794d82ac98c93c1fea9e59474f9bc7625a9b9 --- /dev/null +++ b/core/__init__.py @@ -0,0 +1,35 @@ +"""核心功能模块""" + +from .color import ( + rgb_to_hsb, + rgb_to_lab, + rgb_to_hex, + rgb_to_hsl, + rgb_to_cmyk, + get_color_info, + get_luminance, + get_zone, + get_zone_bounds, + calculate_histogram, + calculate_rgb_histogram, +) + +from .config import ConfigManager, get_config_manager + +__all__ = [ + # 颜色函数 + 'rgb_to_hsb', + 'rgb_to_lab', + 'rgb_to_hex', + 'rgb_to_hsl', + 'rgb_to_cmyk', + 'get_color_info', + 'get_luminance', + 'get_zone', + 'get_zone_bounds', + 'calculate_histogram', + 'calculate_rgb_histogram', + # 配置 + 'ConfigManager', + 'get_config_manager', +] diff --git a/core/color.py b/core/color.py new file mode 100644 index 0000000000000000000000000000000000000000..9e8e52124b2066cab3df1fd36f69c7160a29aa46 --- /dev/null +++ b/core/color.py @@ -0,0 +1,356 @@ +# 标准库导入 +import colorsys +from typing import Dict, List, Tuple + + +def rgb_to_hsb(r: int, g: int, b: int) -> Tuple[float, float, float]: + """将RGB转换为HSB (Hue, Saturation, Brightness) + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + tuple: (色相 0-360, 饱和度 0-100, 亮度 0-100) + """ + r_norm, g_norm, b_norm = r / 255.0, g / 255.0, b / 255.0 + h, s, v = colorsys.rgb_to_hsv(r_norm, g_norm, b_norm) + return h * 360, s * 100, v * 100 + + +def rgb_to_lab(r: int, g: int, b: int) -> Tuple[float, float, float]: + """将RGB转换为LAB颜色空间 + + LAB颜色空间是一种设备无关的颜色空间,L代表亮度(0-100), + A和B代表颜色对立通道(-128到127),适合用于颜色差异计算。 + + 转换步骤: + 1. RGB归一化到0-1范围 + 2. 应用sRGB Gamma校正(转换到线性空间) + 3. 转换为XYZ颜色空间(使用sRGB转换矩阵) + 4. 使用D65参考白点归一化XYZ值 + 5. 转换为LAB颜色空间 + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + tuple: (L 0-100, A -128-127, B -128-127) + """ + # 步骤1: 归一化到 0-1 范围 + r_norm, g_norm, b_norm = r / 255.0, g / 255.0, b / 255.0 + + # 步骤2: 应用gamma校正(转换到线性空间) + # sRGB的gamma曲线近似于gamma 2.2,但使用更精确的分段公式 + # 小于等于0.04045的值使用线性转换,大于0.04045的值使用幂函数 + r_norm = r_norm ** 2.2 if r_norm > 0.04045 else r_norm / 12.92 + g_norm = g_norm ** 2.2 if g_norm > 0.04045 else g_norm / 12.92 + b_norm = b_norm ** 2.2 if b_norm > 0.04045 else b_norm / 12.92 + + # 步骤3: 转换为XYZ颜色空间 + # 使用sRGB到XYZ的转换矩阵(基于CIE 1931标准) + # X = 0.4124564*R + 0.3575761*G + 0.1804375*B + # Y = 0.2126729*R + 0.7151522*G + 0.0721750*B + # Z = 0.0193339*R + 0.1191920*G + 0.9503041*B + x = r_norm * 0.4124564 + g_norm * 0.3575761 + b_norm * 0.1804375 + y = r_norm * 0.2126729 + g_norm * 0.7151522 + b_norm * 0.0721750 + z = r_norm * 0.0193339 + g_norm * 0.1191920 + b_norm * 0.9503041 + + # 步骤4: 使用D65参考白点归一化XYZ值 + # D65是标准日光白点,色温约6500K,是sRGB和大多数显示器的标准白点 + x_ref, y_ref, z_ref = 0.95047, 1.00000, 1.08883 + x, y, z = x / x_ref, y / y_ref, z / z_ref + + # 步骤5: 转换为LAB颜色空间 + # 使用分段函数f(t)处理非线性转换 + def f(t: float) -> float: + # 大于0.008856的值使用立方根,小于等于的值使用线性转换 + return t ** (1/3) if t > 0.008856 else 7.787 * t + 16/116 + + # L = 116*f(Y) - 16 (亮度分量) + # A = 500*(f(X) - f(Y)) (红绿对立分量) + # B = 200*(f(Y) - f(Z)) (黄蓝对立分量) + l = 116 * f(y) - 16 + a_val = 500 * (f(x) - f(y)) + b_val = 200 * (f(y) - f(z)) + + return l, a_val, b_val + + +def rgb_to_hex(r: int, g: int, b: int) -> str: + """将RGB转换为16进制颜色值 + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + str: 16进制颜色值,如 "#FF0000" + """ + return f"#{r:02X}{g:02X}{b:02X}" + + +def rgb_to_hsl(r: int, g: int, b: int) -> Tuple[float, float, float]: + """将RGB转换为HSL (Hue, Saturation, Lightness) + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + tuple: (色相 0-360, 饱和度 0-100, 亮度 0-100) + """ + r_norm, g_norm, b_norm = r / 255.0, g / 255.0, b / 255.0 + h, l, s = colorsys.rgb_to_hls(r_norm, g_norm, b_norm) + return h * 360, s * 100, l * 100 + + +def rgb_to_cmyk(r: int, g: int, b: int) -> Tuple[float, float, float, float]: + """将RGB转换为CMYK (Cyan, Magenta, Yellow, Key/Black) + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + tuple: (C 0-100, M 0-100, Y 0-100, K 0-100) + """ + r_norm, g_norm, b_norm = r / 255.0, g / 255.0, b / 255.0 + + k = 1 - max(r_norm, g_norm, b_norm) + if k == 1: + return 0, 0, 0, 100 + + c = (1 - r_norm - k) / (1 - k) + m = (1 - g_norm - k) / (1 - k) + y = (1 - b_norm - k) / (1 - k) + + return c * 100, m * 100, y * 100, k * 100 + + +def get_color_info(r: int, g: int, b: int) -> Dict[str, any]: + """获取颜色的完整信息 + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + dict: 包含RGB、HSB、LAB、HEX、HSL、CMYK颜色信息的字典 + """ + h, s, b_val = rgb_to_hsb(r, g, b) + l, a, b_lab = rgb_to_lab(r, g, b) + h_hsl, s_hsl, l_hsl = rgb_to_hsl(r, g, b) + c, m, y, k = rgb_to_cmyk(r, g, b) + + return { + 'rgb': (r, g, b), + 'hsb': (round(h), round(s), round(b_val)), + 'lab': (round(l), round(a), round(b_lab)), + 'hsl': (round(h_hsl), round(s_hsl), round(l_hsl)), + 'cmyk': (round(c), round(m), round(y), round(k)), + 'rgb_display': (r, g, b), + 'hex': rgb_to_hex(r, g, b) + } + + +def get_luminance(r: int, g: int, b: int) -> int: + """计算像素的明度值 (0-255) + + 使用 Rec. 709 标准计算亮度值,包含 sRGB Gamma 校正 + 这是 Lightroom、Photoshop 等专业软件使用的标准方法 + + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) + + Returns: + int: 明度值 (0-255) + """ + # 步骤1: 归一化到 0-1 范围 + r_norm = r / 255.0 + g_norm = g / 255.0 + b_norm = b / 255.0 + + # 步骤2: sRGB Gamma 解码(转换到线性空间) + # sRGB 的 gamma 曲线近似于 gamma 2.2,但使用更精确的公式 + def srgb_to_linear(c: float) -> float: + if c <= 0.04045: + return c / 12.92 + else: + return ((c + 0.055) / 1.055) ** 2.4 + + r_linear = srgb_to_linear(r_norm) + g_linear = srgb_to_linear(g_norm) + b_linear = srgb_to_linear(b_norm) + + # 步骤3: 在线性空间应用 Rec. 709 权重 + luminance_linear = 0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear + + # 步骤4: 将结果编码回 sRGB Gamma 空间(为了显示一致性) + def linear_to_srgb(c: float) -> float: + if c <= 0.0031308: + return c * 12.92 + else: + return 1.055 * (c ** (1.0 / 2.4)) - 0.055 + + luminance_srgb = linear_to_srgb(luminance_linear) + + # 步骤5: 转换回 0-255 范围 + return min(255, round(luminance_srgb * 255)) + + +def get_zone(luminance: int) -> str: + """根据明度值返回区域编号 + + 将 0-255 的明度值分为8个区域: + Zone 0-1: 0-31 (最暗) + Zone 1-2: 32-63 + Zone 2-3: 64-95 + Zone 3-4: 96-127 + Zone 4-5: 128-159 + Zone 5-6: 160-191 + Zone 6-7: 192-223 + Zone 7-8: 224-255 (最亮) + + Args: + luminance: 明度值 (0-255) + + Returns: + str: 区域编号字符串,如 "3-4" + """ + zone_index = min(luminance // 32, 7) + return f"{zone_index}-{zone_index + 1}" + + +def get_zone_bounds(zone_str: str) -> Tuple[int, int]: + """获取区域对应的明度范围 + + Args: + zone_str: 区域编号,如 "3-4" + + Returns: + tuple: (min_luminance, max_luminance) 元组 + """ + start = int(zone_str.split('-')[0]) + return (start * 32, (start + 1) * 32 - 1) + + +def calculate_histogram(image, sample_step: int = 4) -> List[int]: + """计算图片的明度直方图(使用采样优化) + + Args: + image: QImage 对象 + sample_step: 采样步长,每隔N个像素采样一次(默认4,即1/16的像素) + + Returns: + list: 长度为256的列表,表示每个明度值的像素数量 + """ + histogram = [0] * 256 + + if image is None or image.isNull(): + return histogram + + width = image.width() + height = image.height() + + # 采样计算直方图,大幅提高性能 + # 确保包含边缘像素,使用 min 函数防止越界 + for y in range(0, height, sample_step): + for x in range(0, width, sample_step): + color = image.pixelColor(x, y) + luminance = get_luminance(color.red(), color.green(), color.blue()) + histogram[luminance] += 1 + + # 额外采样最右侧和最底部的边缘像素,确保高亮区域不被遗漏 + # 采样最右列 + if width > 0: + right_x = width - 1 + for y in range(0, height, sample_step): + color = image.pixelColor(right_x, y) + luminance = get_luminance(color.red(), color.green(), color.blue()) + histogram[luminance] += 1 + + # 采样最底行 + if height > 0: + bottom_y = height - 1 + for x in range(0, width, sample_step): + color = image.pixelColor(x, bottom_y) + luminance = get_luminance(color.red(), color.green(), color.blue()) + histogram[luminance] += 1 + + # 采样右下角像素(如果尚未被采样) + if width > 0 and height > 0: + color = image.pixelColor(width - 1, height - 1) + luminance = get_luminance(color.red(), color.green(), color.blue()) + histogram[luminance] += 1 + + return histogram + + +def calculate_rgb_histogram(image, sample_step: int = 4) -> Tuple[List[int], List[int], List[int]]: + """计算图片的RGB直方图(使用采样优化) + + Args: + image: QImage 对象 + sample_step: 采样步长,每隔N个像素采样一次(默认4,即1/16的像素) + + Returns: + tuple: 三个长度为256的列表的元组 (R_histogram, G_histogram, B_histogram) + """ + histogram_r = [0] * 256 + histogram_g = [0] * 256 + histogram_b = [0] * 256 + + if image is None or image.isNull(): + return histogram_r, histogram_g, histogram_b + + width = image.width() + height = image.height() + + # 采样计算直方图,大幅提高性能 + for y in range(0, height, sample_step): + for x in range(0, width, sample_step): + color = image.pixelColor(x, y) + r = color.red() + g = color.green() + b = color.blue() + histogram_r[r] += 1 + histogram_g[g] += 1 + histogram_b[b] += 1 + + # 额外采样最右侧和最底部的边缘像素,确保高亮区域不被遗漏 + # 采样最右列 + if width > 0: + right_x = width - 1 + for y in range(0, height, sample_step): + color = image.pixelColor(right_x, y) + histogram_r[color.red()] += 1 + histogram_g[color.green()] += 1 + histogram_b[color.blue()] += 1 + + # 采样最底行 + if height > 0: + bottom_y = height - 1 + for x in range(0, width, sample_step): + color = image.pixelColor(x, bottom_y) + histogram_r[color.red()] += 1 + histogram_g[color.green()] += 1 + histogram_b[color.blue()] += 1 + + # 采样右下角像素(如果尚未被采样) + if width > 0 and height > 0: + color = image.pixelColor(width - 1, height - 1) + histogram_r[color.red()] += 1 + histogram_g[color.green()] += 1 + histogram_b[color.blue()] += 1 + + return histogram_r, histogram_g, histogram_b diff --git a/core/config.py b/core/config.py new file mode 100644 index 0000000000000000000000000000000000000000..dcb36e818570b3a892475cacd6f15cbd1bb9bd33 --- /dev/null +++ b/core/config.py @@ -0,0 +1,190 @@ +# 标准库导入 +import json +from pathlib import Path +from typing import Any, Dict, Optional + +# 项目模块导入 +from version import version_manager + + +class ConfigManager: + """配置管理器,处理应用程序配置的加载和保存""" + + CONFIG_VERSION: str = "1.0" + CONFIG_DIR_NAME: str = ".color_card" + CONFIG_FILE_NAME: str = "config.json" + + def __init__(self) -> None: + """初始化配置管理器""" + self._config_path: Path = self._get_config_path() + self._config: Dict[str, Any] = {} + self._load_default_config() + + def _get_config_path(self) -> Path: + """获取配置文件路径 + + Returns: + Path: 配置文件的完整路径 + """ + home_dir = Path.home() + config_dir = home_dir / self.CONFIG_DIR_NAME + return config_dir / self.CONFIG_FILE_NAME + + def _ensure_config_dir(self) -> None: + """确保配置目录存在""" + config_dir = self._config_path.parent + config_dir.mkdir(parents=True, exist_ok=True) + + def _load_default_config(self) -> None: + """加载默认配置""" + self._config = { + "version": self.CONFIG_VERSION, + "app_version": version_manager.get_version(), + "settings": { + "hex_visible": True, + "color_modes": ["HSB", "LAB"], + "color_sample_count": 5, + "luminance_sample_count": 5 + }, + "window": { + "width": 940, + "height": 660, + "is_maximized": False + } + } + + def load(self) -> Dict[str, Any]: + """从文件加载配置 + + Returns: + Dict[str, Any]: 加载的配置字典 + """ + if not self._config_path.exists(): + return self._config + + try: + with open(self._config_path, 'r', encoding='utf-8') as f: + loaded_config = json.load(f) + + # 合并加载的配置和默认配置(保留默认值作为后备) + self._merge_config(self._config, loaded_config) + + except (json.JSONDecodeError, IOError, OSError) as e: + print(f"加载配置文件失败: {e}") + # 使用默认配置 + + return self._config + + def _merge_config(self, base: Dict[str, Any], override: Dict[str, Any]) -> None: + """递归合并配置字典 + + Args: + base: 基础配置字典(会被修改) + override: 覆盖配置字典 + """ + for key, value in override.items(): + if key in base and isinstance(base[key], dict) and isinstance(value, dict): + self._merge_config(base[key], value) + else: + base[key] = value + + def save(self, config: Optional[Dict[str, Any]] = None) -> None: + """保存配置到文件 + + Args: + config: 要保存的配置字典,如果为 None 则保存当前配置 + """ + if config is not None: + self._config = config + + self._ensure_config_dir() + + try: + with open(self._config_path, 'w', encoding='utf-8') as f: + json.dump(self._config, f, ensure_ascii=False, indent=4) + except (IOError, OSError) as e: + print(f"保存配置文件失败: {e}") + + def get(self, key: str, default: Any = None) -> Any: + """获取配置项 + + Args: + key: 配置键,支持点号分隔的嵌套路径(如 "settings.hex_visible") + default: 默认值 + + Returns: + Any: 配置值,如果不存在则返回默认值 + """ + keys = key.split('.') + value = self._config + + for k in keys: + if isinstance(value, dict) and k in value: + value = value[k] + else: + return default + return value + + def set(self, key: str, value: Any) -> None: + """设置配置项 + + Args: + key: 配置键,支持点号分隔的嵌套路径(如 "settings.hex_visible") + value: 配置值 + """ + keys = key.split('.') + config = self._config + + for k in keys[:-1]: + if k not in config: + config[k] = {} + config = config[k] + + config[keys[-1]] = value + + def get_settings(self) -> Dict[str, Any]: + """获取设置配置 + + Returns: + Dict[str, Any]: 设置配置字典 + """ + return self._config.get("settings", {}) + + def set_settings(self, settings: Dict[str, Any]) -> None: + """设置设置配置 + + Args: + settings: 设置配置字典 + """ + self._config["settings"] = settings + + def get_window_config(self) -> Dict[str, Any]: + """获取窗口配置 + + Returns: + Dict[str, Any]: 窗口配置字典 + """ + return self._config.get("window", {}) + + def set_window_config(self, window_config: Dict[str, Any]) -> None: + """设置窗口配置 + + Args: + window_config: 窗口配置字典 + """ + self._config["window"] = window_config + +# 全局配置管理器实例 +_config_manager: Optional[ConfigManager] = None + + +def get_config_manager() -> ConfigManager: + """获取全局配置管理器实例 + + Returns: + ConfigManager: 配置管理器实例 + """ + global _config_manager + if _config_manager is None: + _config_manager = ConfigManager() + return _config_manager diff --git a/dialogs/__init__.py b/dialogs/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..756e0c10f8217e318b20c273ca8f8c5727879b13 --- /dev/null +++ b/dialogs/__init__.py @@ -0,0 +1,9 @@ +"""对话框模块""" + +from .about_dialog import AboutDialog +from .update_dialog import UpdateAvailableDialog + +__all__ = [ + 'AboutDialog', + 'UpdateAvailableDialog', +] diff --git a/dialogs/about_dialog.py b/dialogs/about_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..77c3fb395e7e1298e3572dadd4f674f41d969415 --- /dev/null +++ b/dialogs/about_dialog.py @@ -0,0 +1,235 @@ +# 标准库导入 +from pathlib import Path + +# 第三方库导入 +from PySide6.QtCore import Qt, QTimer, QUrl +from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtWidgets import ( + QDialog, QFrame, QHBoxLayout, QPlainTextEdit, QVBoxLayout, QWidget +) +from qfluentwidgets import CaptionLabel, PrimaryPushButton, PushButton, isDarkTheme + +# 项目模块导入 +from utils import fix_windows_taskbar_icon_for_window, load_icon_universal +from version import version_manager + + +def get_background_color(): + """获取主题背景颜色""" + return QColor(32, 32, 32) if isDarkTheme() else QColor(255, 255, 255) + + +def get_text_color(): + """获取主题文本颜色""" + return QColor(255, 255, 255) if isDarkTheme() else QColor(40, 40, 40) + + +class AboutDialog(QDialog): + """关于对话框 + + 显示应用程序信息、版本信息、开发团队信息、 + 相关链接和版权声明。 + """ + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("关于") + self.setFixedSize(600, 550) + + # 设置窗口图标 + self.setWindowIcon(load_icon_universal()) + + # 设置窗口标志:只保留关闭按钮(必须在设置窗口标题之后) + self.setWindowFlags( + Qt.WindowType.Window | + Qt.WindowType.WindowTitleHint | + Qt.WindowType.WindowCloseButtonHint | + Qt.WindowType.CustomizeWindowHint + ) + + # 设置窗口背景色(与 FluentWindow 一致) + bg_color = get_background_color() + self.setStyleSheet(f"QDialog {{ background-color: {bg_color.name()}; }}") + + self.setup_ui() + + # 修复任务栏图标(在窗口显示后调用) + QTimer.singleShot(100, lambda: fix_windows_taskbar_icon_for_window(self)) + + def setup_ui(self): + """设置界面布局""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # 内容区域 + self._create_content_area(layout) + + # 按钮区域 + self._create_buttons_area(layout) + + # 版权信息 + self._create_copyright(layout) + + def _create_content_area(self, parent_layout): + """创建内容显示区域 + + Args: + parent_layout: 父布局对象 + """ + self.text_edit = QPlainTextEdit(self) + self.text_edit.setReadOnly(True) + self.text_edit.setPlainText(self._get_about_text()) + self.text_edit.setContextMenuPolicy(Qt.ContextMenuPolicy.NoContextMenu) + + # 设置主题感知的样式 + bg_color = get_background_color() + text_color = get_text_color() + self.text_edit.setStyleSheet( + f"QPlainTextEdit {{ background-color: {bg_color.name()}; " + f"color: {text_color.name()}; border: none; }}" + ) + + parent_layout.addWidget(self.text_edit, stretch=1) + + + + def _create_buttons_area(self, parent_layout): + """创建按钮区域 + + Args: + parent_layout: 父布局对象 + """ + buttons_container = QWidget() + buttons_layout = QHBoxLayout(buttons_container) + buttons_layout.setSpacing(10) + buttons_layout.setContentsMargins(0, 0, 0, 0) + + buttons_layout.addStretch() + + # 个人主页按钮 + self.homepage_button = PushButton("个人主页") + self.homepage_button.setMinimumWidth(90) + self.homepage_button.clicked.connect( + lambda: self._open_url("https://space.bilibili.com/1232406878") + ) + buttons_layout.addWidget(self.homepage_button) + + # 项目地址按钮(主题色) + self.project_button = PrimaryPushButton("项目地址") + self.project_button.setMinimumWidth(90) + self.project_button.clicked.connect( + lambda: self._open_url("https://gitee.com/qingshangongzai/color_card") + ) + buttons_layout.addWidget(self.project_button) + + # 开源许可按钮 + self.license_button = PushButton("开源许可") + self.license_button.setMinimumWidth(90) + self.license_button.clicked.connect(self._open_license_file) + buttons_layout.addWidget(self.license_button) + + # 用户协议按钮 + self.agreement_button = PushButton("用户协议") + self.agreement_button.setMinimumWidth(90) + self.agreement_button.clicked.connect(self._open_agreement_file) + buttons_layout.addWidget(self.agreement_button) + + buttons_layout.addStretch() + + parent_layout.addWidget(buttons_container) + + def _create_copyright(self, parent_layout): + """创建版权信息 + + Args: + parent_layout: 父布局对象 + """ + app_info = version_manager.get_app_info() + + copyright_label = CaptionLabel( + f"版权所有 {app_info['copyright']}\n" + "基于 GPL v3 开源,仅供学习交流使用" + ) + copyright_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + parent_layout.addWidget(copyright_label) + + def _open_url(self, url): + """打开URL链接 + + Args: + url: 要打开的URL地址 + """ + QDesktopServices.openUrl(QUrl(url)) + + def _open_license_file(self): + """打开开源许可文件""" + # 获取许可证文件路径(相对于项目根目录的 file/LICENSE.html) + license_path = Path(__file__).parent.parent / "file" / "LICENSE.html" + + if license_path.exists(): + # 转换为文件URL并打开 + file_url = QUrl.fromLocalFile(str(license_path.absolute())) + QDesktopServices.openUrl(file_url) + else: + # 如果文件不存在,打开项目地址 + self._open_url("https://gitee.com/qingshangongzai/color_card") + + def _open_agreement_file(self): + """打开用户协议文件""" + # 获取用户协议文件路径(相对于项目根目录的 file/UserAgreement.html) + agreement_path = Path(__file__).parent.parent / "file" / "UserAgreement.html" + + if agreement_path.exists(): + # 转换为文件URL并打开 + file_url = QUrl.fromLocalFile(str(agreement_path.absolute())) + QDesktopServices.openUrl(file_url) + else: + # 如果文件不存在,打开项目地址 + self._open_url("https://gitee.com/qingshangongzai/color-card") + + def _get_about_text(self): + """获取关于页面的文本内容""" + app_info = version_manager.get_app_info() + version = version_manager.get_version() + + return f"""  取色卡(Color Card)是一款专为摄影师开发的图片分析小工具,旨在帮助摄影爱好者和专业人士快速分析图像的色彩分布、亮度信息等关键数据,辅助后期调色和色彩管理。 + +【开发团队】 + • 出品:{app_info['company']} + • 开发:{app_info['developer']} + • 代码:Trae + • 联系邮箱:{app_info['email']} + +【开源项目使用说明】 + • 本程序基于 PySide6 架构开发,许可证:LGPL v3 + 版权所有:The Qt Company + 项目地址:https://www.qt.io/ + + • 本程序 UI 组件使用 PySide6-Fluent-Widgets,许可证:GPLv3 + 项目地址:https://github.com/zhiyiYo/PyQt-Fluent-Widgets + + • 本程序使用 requests 库进行网络请求,许可证:Apache-2.0 + 项目地址:https://github.com/psf/requests + + • 本程序使用 Pillow 库处理图像,许可证:MIT + 项目地址:https://github.com/python-pillow/Pillow + + • 本程序使用auto-py-to-exe工具打包为独立的可执行文件。 + 项目地址:https://github.com/brentvollebregt/auto-py-to-exe + + • 本程序使用UPX工具压缩可执行文件体积。 + 官网:https://upx.github.io/ + + • 本程序使用Inno Setup工具将独立的可执行文件打包为安装程序。 + 官网:https://jrsoftware.org/isinfo.php + +【特别鸣谢】 + • 感谢 PySide6 和 PyQt-Fluent-Widgets 开发团队提供的优秀框架 + • 感谢 Trae IDE 提供的 AI 辅助编程支持 +""" + + def contextMenuEvent(self, event): + """屏蔽原生右键菜单""" + event.ignore() diff --git a/dialogs/update_dialog.py b/dialogs/update_dialog.py new file mode 100644 index 0000000000000000000000000000000000000000..e4566167ac5959e4de9deff3315c3a50e9a2e77e --- /dev/null +++ b/dialogs/update_dialog.py @@ -0,0 +1,243 @@ +# 标准库导入 +import re + +# 第三方库导入 +from PySide6.QtCore import Qt, QThread, QTimer, QUrl, Signal +from PySide6.QtGui import QColor, QDesktopServices +from PySide6.QtWidgets import QDialog, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from qfluentwidgets import InfoBar, InfoBarPosition, PrimaryPushButton, PushButton, isDarkTheme + +try: + import requests +except ImportError: + requests = None + +# 项目模块导入 +from utils import fix_windows_taskbar_icon_for_window, load_icon_universal + + +class UpdateCheckThread(QThread): + """检查更新的后台线程 + + 在后台线程中检查 Gitee 仓库的最新版本信息, + 避免阻塞主线程。 + """ + + check_finished = Signal(bool, str, str) + + def __init__(self, current_version): + """初始化检查更新线程 + + Args: + current_version: 当前版本号 + """ + super().__init__() + self.current_version = current_version + + def run(self): + """在后台线程中检查更新""" + try: + if requests is None: + self.check_finished.emit(False, "", "缺少 requests 库,无法检查更新") + return + + api_url = "https://gitee.com/api/v5/repos/qingshangongzai/color_card/releases/latest" + response = requests.get(api_url, timeout=10) + + if response.status_code == 200: + data = response.json() + latest_version = data.get("tag_name", "").lstrip("v") + + if latest_version: + self.check_finished.emit(True, latest_version, "") + else: + self.check_finished.emit(False, "", "无法解析版本信息") + else: + self.check_finished.emit( + False, "", f"获取版本信息失败: HTTP {response.status_code}" + ) + + except requests.exceptions.Timeout: + self.check_finished.emit(False, "", "连接超时,请检查网络连接") + except requests.exceptions.ConnectionError: + self.check_finished.emit(False, "", "网络连接失败,请检查网络设置") + except Exception as e: + self.check_finished.emit(False, "", f"检查更新时出错: {str(e)}") + + +def compare_versions(current, latest): + """比较版本号 + + Args: + current: 当前版本号 + latest: 最新版本号 + + Returns: + int: 0表示版本相同,1表示当前版本更新,-1表示有新版本 + """ + + def parse_version(version_str): + """解析版本号为数字列表""" + version_str = version_str.lstrip("v") + parts = re.findall(r"\d+", version_str) + return [int(p) for p in parts] if parts else [0] + + current_parts = parse_version(current) + latest_parts = parse_version(latest) + + max_len = max(len(current_parts), len(latest_parts)) + current_parts.extend([0] * (max_len - len(current_parts))) + latest_parts.extend([0] * (max_len - len(latest_parts))) + + for c, l in zip(current_parts, latest_parts): + if c > l: + return 1 + elif c < l: + return -1 + + return 0 + + +class UpdateAvailableDialog(QDialog): + """新版本可用提示对话框 + + 当检测到有新版本时弹出,提供跳转到发行页面的功能。 + """ + + _check_thread = None # 类变量,用于保存检查更新的线程对象 + + def __init__(self, parent=None, current_version="", latest_version=""): + """初始化新版本提示对话框 + + Args: + parent: 父窗口对象 + current_version: 当前版本号 + latest_version: 最新版本号 + """ + super().__init__(parent) + self.setWindowTitle("发现新版本") + self.setFixedSize(400, 200) + self.current_version = current_version + self.latest_version = latest_version + + # 设置窗口图标 + self.setWindowIcon(load_icon_universal()) + + # 设置窗口标志 + self.setWindowFlags( + Qt.WindowType.Window + | Qt.WindowType.WindowTitleHint + | Qt.WindowType.WindowCloseButtonHint + | Qt.WindowType.CustomizeWindowHint + ) + + # 设置窗口背景色 + bg_color = QColor(32, 32, 32) if isDarkTheme() else QColor(255, 255, 255) + self.setStyleSheet(f"QDialog {{ background-color: {bg_color.name()}; }}") + + self.setup_ui() + + # 修复任务栏图标 + QTimer.singleShot(100, lambda: fix_windows_taskbar_icon_for_window(self)) + + def setup_ui(self): + """设置界面布局""" + layout = QVBoxLayout(self) + layout.setContentsMargins(20, 20, 20, 20) + layout.setSpacing(15) + + # 提示文本 + text_color = QColor(255, 255, 255) if isDarkTheme() else QColor(40, 40, 40) + info_label = QLabel("有新版本可以更新") + info_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + info_label.setStyleSheet( + f"QLabel {{ color: {text_color.name()}; font-size: 16px; font-weight: bold; }}" + ) + layout.addWidget(info_label) + + # 版本信息 + version_label = QLabel(f"当前版本: {self.current_version} → 最新版本: {self.latest_version}") + version_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + version_label.setStyleSheet(f"QLabel {{ color: {text_color.name()}; font-size: 12px; }}") + layout.addWidget(version_label) + + layout.addStretch() + + # 按钮区域 + buttons_container = QWidget() + buttons_layout = QHBoxLayout(buttons_container) + buttons_layout.setSpacing(10) + buttons_layout.setContentsMargins(0, 0, 0, 0) + + buttons_layout.addStretch() + + # 取消按钮 + cancel_button = PushButton("取消") + cancel_button.setMinimumWidth(90) + cancel_button.clicked.connect(self.reject) + buttons_layout.addWidget(cancel_button) + + # 发行页面按钮(主题色) + release_button = PrimaryPushButton("发行页面") + release_button.setMinimumWidth(90) + release_button.clicked.connect(self.open_release_page) + buttons_layout.addWidget(release_button) + + buttons_layout.addStretch() + + layout.addWidget(buttons_container) + + def open_release_page(self): + """打开 Gitee 发行版页面""" + url = "https://gitee.com/qingshangongzai/color_card/releases" + QDesktopServices.openUrl(QUrl(url)) + self.accept() + + @staticmethod + def check_update(parent, current_version): + """检查更新并显示相应提示 + + Args: + parent: 父窗口对象 + current_version: 当前版本号 + """ + if requests is None: + InfoBar.warning( + title="提示", + content="缺少 requests 库,无法检查更新", + parent=parent, + duration=3000, + position=InfoBarPosition.TOP, + ) + return + + # 创建并启动检查线程 + UpdateAvailableDialog._check_thread = UpdateCheckThread(current_version) + + def on_check_finished(success, latest_version, error_msg): + if success: + result = compare_versions(current_version, latest_version) + if result >= 0: + # 当前版本已是最新 + InfoBar.info( + title="提示", + content="当前已是最新版本", + parent=parent, + duration=3000, + position=InfoBarPosition.TOP, + ) + else: + # 有新版本可用,显示对话框 + dialog = UpdateAvailableDialog(parent, current_version, latest_version) + dialog.exec() + else: + InfoBar.warning( + title="检查更新失败", + content=error_msg, + parent=parent, + duration=5000, + position=InfoBarPosition.TOP, + ) + + UpdateAvailableDialog._check_thread.check_finished.connect(on_check_finished) + UpdateAvailableDialog._check_thread.start() diff --git a/file/LICENSE.html b/file/LICENSE.html new file mode 100644 index 0000000000000000000000000000000000000000..f75bc85f4a8e66b11a4ff7b6b3c86ceb12e74a92 --- /dev/null +++ b/file/LICENSE.html @@ -0,0 +1,501 @@ + + + + + + 取色卡(Color Card) - 许可证 + + + +
+

取色卡(Color Card) - 许可证信息

+ +
+

项目信息

+

项目名称:取色卡(Color Card)

+

版权所有:© 2026 浮晓 HXiao Studio

+

开发者:青山公仔

+

联系方式:hxiao_studio@163.com

+
+ +
+

作者声明

+

虽然GPL v3允许商业使用,但作为作者,我恳请使用者遵守以下原则:

+
    +
  • 🔒 非商业使用:请勿将此工具用于商业盈利目的
  • +
  • 🤝 共享改进:欢迎提交改进,但请保持开源精神
  • +
+

请注意,这只是作者的道德请求,法律上仍遵循GPL v3条款。

+
+ +
+

⚠️ 重要提示

+

本项目使用 PySide6 (LGPLv3),因此整个项目也必须以 GPLv3 开源!

+

本项目包含以下许可证:

+
    +
  1. 主项目许可证:GNU General Public License v3.0 (GPLv3)
  2. +
  3. 第三方库许可证:包含 LGPL-3.0、GPL-3.0、MIT 等多种开源许可证
  4. +
+

详细的第三方库许可证信息请查看后续章节或应用程序的"关于"窗口。

+
+ +
+
+

GNU GENERAL PUBLIC LICENSE

+

Version 3, 29 June 2007

+
+ + + +

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:

+
    +
  1. a) The work must carry prominent notices stating that you modified it, and giving a relevant date.
  2. +
  3. 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".
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
+

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:

+
    +
  1. 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.
  2. +
  3. 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.
  4. +
  5. 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.
  6. +
  7. 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.
  8. +
  9. 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.
  10. +
+

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:

+
    +
  1. a) Disclaiming warranty or limiting liability differently from the terms of sections 15 and 16 of this License; or
  2. +
  3. 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
  4. +
  5. 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
  6. +
  7. d) Limiting the use for publicity purposes of names of licensors or authors of the material; or
  8. +
  9. e) Declining to grant rights under trademark law for use of some trade names, trademarks, or service marks; or
  10. +
  11. 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.
  12. +
+

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.

+
+ <one line to give the program's name and a brief idea of what it does.>
+ Copyright (C) <year> <name of author>

+ 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 <https://www.gnu.org/licenses/>. +
+

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:

+
+ <program> Copyright (C) <year> <name of author>
+ 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 <https://www.gnu.org/licenses/>.

+

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 <https://www.gnu.org/licenses/why-not-lgpl.html>.

+
+
+ +
+

第三方库许可证

+

本项目使用了以下第三方库,每个库都有其自己的开源许可证:

+ +

LGPL-3.0

+

适用库: PySide6

+ +

GPL-3.0

+

适用库: PySide6-Fluent-Widgets

+ +

MIT License

+

适用库: Pillow

+ +

Apache-2.0

+

适用库: requests

+
+ +
+

使用说明

+
+

许可证约束

+
    +
  • 本项目整体受 GNU General Public License v3.0 约束
  • +
  • 使用本软件即表示您同意遵守所有相关许可证条款
  • +
+
+ +
+

根据 GPLv3 要求:

+
    +
  • ✅ 您可以自由使用、修改、分发本软件
  • +
  • ✅ 您必须以 GPLv3 许可证开源您的修改版本
  • +
  • ✅ 您必须提供源代码
  • +
  • ❌ 您不能将本软件用于闭源商业项目
  • +
+
+ +

如有疑问,请联系: hxiao_studio@163.com

+
+ +
+ 许可证文档结束 +
+
+ + diff --git a/file/UserAgreement.html b/file/UserAgreement.html new file mode 100644 index 0000000000000000000000000000000000000000..94901414a2f8f5de8ce597e7499b15c4e7e89a5c --- /dev/null +++ b/file/UserAgreement.html @@ -0,0 +1,154 @@ + + + + + + 取色卡(Color Card)用户服务协议与免责声明 + + + +
+
+

《用户服务协议与免责声明》

+

最后修改时间:2026年02月04日

+
+ +
+
+

欢迎使用取色卡(Color Card)(以下简称"本软件")。一旦您安装、复制、下载、访问或以其他方式使用本软件,即表示您已充分阅读、理解并同意接受本协议的全部内容。如果您不同意本协议的任何条款,请立即停止使用本软件。

+
+ +
+

第一条 软件说明

+

本软件是一款基于 PySide6 开发的图片颜色分析工具,用于从图片中提取颜色信息和分析明度分布。主要功能包括图片导入、色彩提取、多色彩模式显示、明度直方图可视化和色彩卡片生成。

+

本软件为本地运行的桌面应用程序,所有图片处理操作均在用户本地设备上完成,不会上传用户图片数据到任何服务器。

+
+ +
+

第二条 用户责任

+
    +
  1. 您应遵守您所在地的法律法规,合法使用本软件。
  2. +
  3. 在分发本软件或其修改版本时,您必须严格遵守 GPLv3 协议,保留所有原始的版权声明、许可证通知和免责声明。
  4. +
+
+ +
+

第三条 知识产权

+
    +
  1. 本软件及其所有内容(包括但不限于代码、界面设计、文档等)的知识产权均归浮晓 HXiao Studio 所有,受国际版权法和其他知识产权法保护。
  2. +
  3. 本软件使用的第三方开源组件(PySide6、PySide6-Fluent-Widgets、Pillow 等)的知识产权归各自所有者所有,遵循其开源协议。
  4. +
+
+ +
+

第四条 隐私条款

+
    +
  1. 本地运行:本软件完全在您的本地设备上运行,不会收集、上传或传输用户的图片数据。
  2. +
  3. 版本更新检查:本软件包含版本更新检查功能,会连接到 Gitee 平台获取最新版本信息。此过程仅获取公开版本号,不涉及任何个人数据传输。
  4. +
+
+ +
+

第五条 开源许可

+
    +
  1. 本项目基于GNU General Public License v3.0 许可证发布。
  2. +
  3. 虽然 GPLv3 允许商业使用,但作为作者,我们恳请使用者遵守以下原则: +
      +
    • 🔒 非商业使用:请勿将此工具用于商业盈利目的。
    • +
    • 🤝 共享改进:欢迎提交改进,但请保持开源精神。
    • +
    +
  4. +
+
+ +
+

第六条 免责声明

+
    +
  1. 本软件按"原样"提供,不提供任何明示或暗示的担保。
  2. +
  3. 对于因使用本软件分析图片而产生的任何结果,我们不对其准确性、完整性或适用性作出任何保证。
  4. +
  5. 我们承诺本软件不包含任何恶意代码。
  6. +
+
+ +
+

第七条 法律适用

+

本协议的解释、效力及纠纷解决,均适用中华人民共和国的法律

+
+
+
+ + diff --git a/logo/Color Card_logo.ico b/logo/Color Card_logo.ico new file mode 100644 index 0000000000000000000000000000000000000000..b405fb3044e5a3c4dae33b8134467a6fcc7d3164 Binary files /dev/null and b/logo/Color Card_logo.ico differ diff --git a/main.py b/main.py index 273107dea1ba33d3665f945bd6e7aae673464e56..83e558870116bc3b9dae02319d6eee8bc5ebafba 100644 --- a/main.py +++ b/main.py @@ -1,10 +1,46 @@ +# 标准库导入 +import ctypes +import os import sys +from io import StringIO + + +def set_app_user_model_id(): + """设置 Windows AppUserModelID + + 这必须在创建 QApplication 之前调用! + 格式:CompanyName.AppName.Version + """ + if os.name != 'nt': + return False + + try: + app_id = 'HXiaoStudio.ColorCard.1.0.0' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + return True + except Exception: + return False + + +# 立即调用(在导入 PySide6 之前) +set_app_user_model_id() + +# 临时重定向 stdout 以屏蔽 QFluentWidgets 的推广提示 +_old_stdout = sys.stdout +sys.stdout = StringIO() + +# 第三方库导入 +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QIcon from PySide6.QtWidgets import QApplication -from PySide6.QtCore import Qt +from qfluentwidgets import setTheme, setThemeColor, Theme -from qfluentwidgets import FluentWindow, setTheme, Theme, setThemeColor +# 恢复 stdout +sys.stdout = _old_stdout -from widgets import MainWindow +# 项目模块导入 +from utils import fix_windows_taskbar_icon_for_window, load_icon_universal +from ui import MainWindow def main(): @@ -14,11 +50,18 @@ def main(): app = QApplication(sys.argv) + # 设置应用程序图标(重要!) + app_icon = load_icon_universal() + app.setWindowIcon(app_icon) + setTheme(Theme.AUTO) setThemeColor('#0078d4') window = MainWindow() window.show() + + # 修复任务栏图标(在窗口显示后调用) + QTimer.singleShot(100, lambda: fix_windows_taskbar_icon_for_window(window)) sys.exit(app.exec()) diff --git a/requirements.txt b/requirements.txt index 6e2315434d62ddee800e690eed154f6aa8c3609d..42f079c985eebb949e523a6adf52ab933c01ed3b 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,4 @@ -PyQt6>=6.0.0 -PyQt6-Fluent-Widgets>=1.0.0 +PySide6>=6.0.0 +PySide6-Fluent-Widgets>=1.0.0 +Pillow>=9.0.0 +requests>=2.32.0 diff --git a/ui/__init__.py b/ui/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..183bd140dbdcd89f37eaf73a76556b06dad1eaa4 --- /dev/null +++ b/ui/__init__.py @@ -0,0 +1,46 @@ +"""UI模块,统一导出所有UI相关的类和函数""" + +from .main_window import MainWindow +from .canvases import BaseCanvas, ImageCanvas, LuminanceCanvas +from .cards import ( + BaseCard, BaseCardPanel, + ColorCard, ColorCardPanel, + LuminanceCard, LuminanceCardPanel +) +from .histograms import ( + BaseHistogram, + LuminanceHistogramWidget, + RGBHistogramWidget +) +from .color_picker import ColorPicker +from .color_wheel import HSBColorWheel +from .zoom_viewer import ZoomViewer +from .interfaces import ColorExtractInterface, LuminanceExtractInterface, SettingsInterface + +__all__ = [ + # 主窗口 + 'MainWindow', + # 画布 + 'BaseCanvas', + 'ImageCanvas', + 'LuminanceCanvas', + # 卡片 + 'BaseCard', + 'BaseCardPanel', + 'ColorCard', + 'ColorCardPanel', + 'LuminanceCard', + 'LuminanceCardPanel', + # 控件 + 'ColorPicker', + 'HSBColorWheel', + 'ZoomViewer', + # 直方图 + 'BaseHistogram', + 'LuminanceHistogramWidget', + 'RGBHistogramWidget', + # 界面 + 'ColorExtractInterface', + 'LuminanceExtractInterface', + 'SettingsInterface', +] diff --git a/ui/canvases.py b/ui/canvases.py new file mode 100644 index 0000000000000000000000000000000000000000..2d713de2c2966dd0a6a788cbcadb420c5b1223c8 --- /dev/null +++ b/ui/canvases.py @@ -0,0 +1,1117 @@ +# 标准库导入 +import io +from typing import List, Optional, Tuple + +# 第三方库导入 +from PIL import Image +from PySide6.QtCore import QPoint, QPointF, QRect, Qt, QThread, Signal, QTimer +from PySide6.QtGui import QColor, QFont, QImage, QPainter, QPixmap +from PySide6.QtWidgets import QWidget +from qfluentwidgets import Action, FluentIcon, IndeterminateProgressRing, RoundMenu + +# 项目模块导入 +from core import get_luminance, get_zone +from .color_picker import ColorPicker +from .zoom_viewer import ZoomViewer + + +class ImageLoader(QThread): + """图片加载工作线程(使用PIL在子线程读取图片数据)""" + loaded = Signal(bytes, int, int, str) # 信号:图片数据(bytes), 宽度, 高度, 格式 + error = Signal(str) # 信号:错误信息 + + def __init__(self, image_path: str) -> None: + super().__init__() + self._image_path: str = image_path + + def run(self) -> None: + """在子线程中使用PIL加载图片""" + try: + # 使用PIL打开图片(PIL是线程安全的) + with Image.open(self._image_path) as pil_image: + # 转换为RGB模式(处理RGBA、P模式等) + if pil_image.mode != 'RGB': + pil_image = pil_image.convert('RGB') + + # 获取图片尺寸 + width, height = pil_image.size + + # 将图片保存为内存中的BMP格式(无压缩,便于快速转换) + buffer = io.BytesIO() + pil_image.save(buffer, format='BMP') + image_data = buffer.getvalue() + + self.loaded.emit(image_data, width, height, 'BMP') + except (IOError, OSError, ValueError) as e: + self.error.emit(str(e)) + + +class BaseCanvas(QWidget): + """画布基类,提供图片加载、显示和取色点管理的公共功能 + + 功能: + - 异步图片加载 + - 图片显示(保持比例) + - 取色点管理 + - 坐标转换 + - 右键菜单 + + 子类需要实现: + - _setup_after_load(): 图片加载完成后的设置 + - _on_image_load_error(): 图片加载失败的处理 + - extract_all(): 提取所有取色点的数据 + """ + + image_loaded = Signal(str) # 信号:图片路径 + image_data_loaded = Signal(object, object) # 信号:QPixmap, QImage(用于同步到其他面板) + open_image_requested = Signal() # 信号:请求打开图片 + change_image_requested = Signal() # 信号:请求更换图片 + clear_image_requested = Signal() # 信号:请求清空图片 + image_cleared = Signal() # 信号:图片已清空(用于同步到其他面板) + + def __init__(self, parent: Optional[QWidget] = None, picker_count: int = 5) -> None: + super().__init__(parent) + self.setMinimumSize(600, 400) + self.setStyleSheet("background-color: #2a2a2a; border-radius: 8px;") + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self._original_pixmap: Optional[QPixmap] = None + self._image: Optional[QImage] = None + self._picker_positions: List[QPoint] = [] + self._picker_rel_positions: List[QPointF] = [] + self._loader: Optional[ImageLoader] = None + self._pending_image_path: Optional[str] = None + self._picker_count: int = picker_count + + def set_image(self, image_path: str) -> None: + """异步加载并显示图片 + + Args: + image_path: 图片文件路径 + """ + # 保存图片路径 + self._pending_image_path = image_path + + # 如果已有加载线程在运行,先停止 + if self._loader is not None and self._loader.isRunning(): + self._loader.quit() + self._loader.wait() + + # 创建并启动加载线程 + self._loader = ImageLoader(image_path) + self._loader.loaded.connect(self._on_image_loaded) + self._loader.error.connect(self._on_image_load_error) + self._loader.finished.connect(self._cleanup_loader) + self._loader.start() + + def _cleanup_loader(self) -> None: + """清理加载线程""" + if self._loader is not None: + self._loader.deleteLater() + self._loader = None + + def _on_image_loaded(self, image_data: bytes, width: int, height: int, fmt: str) -> None: + """图片加载完成的回调(在主线程中创建QImage/QPixmap) + + Args: + image_data: 图片字节数据 + width: 图片宽度 + height: 图片高度 + fmt: 图片格式 + """ + # 从字节数据创建QImage(在主线程中安全执行) + self._image = QImage.fromData(image_data, fmt) + self._original_pixmap = QPixmap.fromImage(self._image) + self._setup_after_load() + + def _on_image_load_error(self, error_msg: str) -> None: + """图片加载失败的回调 + + Args: + error_msg: 错误信息 + + 子类应重写此方法以提供特定的错误处理 + """ + print(f"图片加载失败: {error_msg}") + + def set_image_data(self, pixmap: QPixmap, image: QImage) -> None: + """直接使用已加载的图片数据(避免重复加载) + + Args: + pixmap: QPixmap 对象 + image: QImage 对象 + """ + self._original_pixmap = pixmap + self._image = image + self._setup_after_load() + + def _setup_after_load(self) -> None: + """图片加载完成后的设置 + + 子类必须实现此方法 + """ + raise NotImplementedError("子类必须实现 _setup_after_load 方法") + + def _init_picker_positions(self) -> None: + """初始化取色点位置到图片中心区域""" + center_x = 0.5 # 使用相对坐标,中心为 0.5 + center_y = 0.5 + + for i in range(self._picker_count): + offset_x = (i - 2) * 0.05 # 使用相对偏移(5%) + self._picker_rel_positions[i] = QPointF(center_x + offset_x, center_y) + + def update_picker_positions(self) -> None: + """更新所有取色点的位置 + + 根据相对坐标(0.0-1.0)计算画布坐标,实现取色点位置 + 在图片缩放时保持相对位置不变。 + + 坐标转换算法: + 1. 获取图片显示区域 (disp_x, disp_y, disp_w, disp_h) + 2. 将相对坐标转换为画布坐标: + canvas_x = disp_x + rel_x * disp_w + canvas_y = disp_y + rel_y * disp_h + 3. 更新取色点UI位置 + + 相对坐标的优势: + - 图片缩放时取色点位置自动调整 + - 图片尺寸变化时保持相对位置不变 + - 便于保存和恢复取色点位置 + """ + # 如果有图片,使用相对坐标计算画布坐标 + if self._image and not self._image.isNull(): + display_rect = self.get_display_rect() + if display_rect: + disp_x, disp_y, disp_w, disp_h = display_rect + + for i in range(self._picker_count): + rel_pos = self._picker_rel_positions[i] + + # 将相对坐标转换为画布坐标 + # 公式:画布坐标 = 显示区域起点 + 相对坐标 × 显示区域尺寸 + canvas_x = disp_x + rel_pos.x() * disp_w + canvas_y = disp_y + rel_pos.y() * disp_h + + # 更新画布坐标存储 + self._picker_positions[i] = QPoint(int(canvas_x), int(canvas_y)) + + # 子类应在此更新取色点显示位置 + self._update_picker_position(i, int(canvas_x), int(canvas_y)) + else: + # 没有图片时,直接使用存储的画布坐标 + for i in range(self._picker_count): + pos = self._picker_positions[i] + self._update_picker_position(i, pos.x(), pos.y()) + + def _update_picker_position(self, index: int, canvas_x: int, canvas_y: int) -> None: + """更新单个取色点的位置 + + Args: + index: 取色点索引 + canvas_x: 画布X坐标 + canvas_y: 画布Y坐标 + + 子类必须实现此方法 + """ + raise NotImplementedError("子类必须实现 _update_picker_position 方法") + + def set_picker_count(self, count: int) -> None: + """设置取色点数量 + + Args: + count: 取色点数量 (2-5) + """ + if count < 2 or count > 5: + return + + if count == self._picker_count: + return + + old_count = self._picker_count + self._picker_count = count + + if count > old_count: + # 增加取色点 + for i in range(old_count, count): + # 新取色点位置在最后一个取色点旁边 + if old_count > 0: + last_rel_pos = self._picker_rel_positions[-1] + new_rel_pos = QPointF(last_rel_pos.x() + 0.05, last_rel_pos.y()) + else: + new_rel_pos = QPointF(0.5, 0.5) # 默认在中心 + self._picker_positions.append(QPoint(100, 100)) # 临时画布坐标 + self._picker_rel_positions.append(new_rel_pos) + self._on_picker_added(i) + else: + # 减少取色点 + for i in range(old_count - 1, count - 1, -1): + self._picker_positions.pop() + self._picker_rel_positions.pop() + self._on_picker_removed(i) + + self.update_picker_positions() + + # 如果有图片,重新提取数据 + if self._image and not self._image.isNull(): + self.extract_all() + + def _on_picker_added(self, index: int) -> None: + """添加取色点时的回调 + + Args: + index: 取色点索引 + + 子类应重写此方法以创建取色点UI + """ + pass + + def _on_picker_removed(self, index: int) -> None: + """移除取色点时的回调 + + Args: + index: 取色点索引 + + 子类应重写此方法以移除取色点UI + """ + pass + + def on_picker_moved(self, index: int, new_pos: QPoint) -> None: + """取色点移动时的回调 + + 将取色点的画布坐标转换为相对坐标并存储,确保在图片缩放时 + 取色点位置保持不变。 + + 坐标转换算法(画布坐标 → 相对坐标): + 1. 获取图片显示区域 (disp_x, disp_y, disp_w, disp_h) + 2. 计算相对坐标: + rel_x = (画布X - 显示区域X) / 显示区域宽度 + rel_y = (画布Y - 显示区域Y) / 显示区域高度 + 3. 限制相对坐标在 [0.0, 1.0] 范围内 + + Args: + index: 取色点索引 + new_pos: 新的画布坐标位置 + """ + # 更新画布坐标 + self._picker_positions[index] = new_pos + + # 如果有图片,将画布坐标转换为相对坐标并存储 + if self._image and not self._image.isNull(): + display_rect = self.get_display_rect() + if display_rect: + disp_x, disp_y, disp_w, disp_h = display_rect + + # 将画布坐标转换为相对坐标 + # 公式:相对坐标 = (画布坐标 - 显示区域起点) / 显示区域尺寸 + rel_x = (new_pos.x() - disp_x) / disp_w + rel_y = (new_pos.y() - disp_y) / disp_h + + # 限制在图片范围内(防止取色点超出图片边界) + rel_x = max(0.0, min(1.0, rel_x)) + rel_y = max(0.0, min(1.0, rel_y)) + + # 更新相对坐标 + self._picker_rel_positions[index] = QPointF(rel_x, rel_y) + + self.extract_at(index) + self.update() + + def extract_at(self, index: int) -> None: + """提取指定取色点的数据 + + Args: + index: 取色点索引 + + 子类必须实现此方法 + """ + raise NotImplementedError("子类必须实现 extract_at 方法") + + def extract_all(self) -> None: + """提取所有取色点的数据 + + 子类必须实现此方法 + """ + raise NotImplementedError("子类必须实现 extract_all 方法") + + def canvas_to_image_pos(self, canvas_pos: QPoint) -> Optional[QPoint]: + """将画布坐标转换为原始图片坐标 + + 坐标转换算法(画布坐标 → 原始图片坐标): + 1. 获取图片显示区域 (disp_x, disp_y, disp_w, disp_h) + 2. 计算在显示区域内的相对位置: + img_x = 画布X - 显示区域X + img_y = 画布Y - 显示区域Y + 3. 检查是否在显示区域内 + 4. 计算缩放比例: + scale_x = 原始图片宽度 / 显示区域宽度 + scale_y = 原始图片高度 / 显示区域高度 + 5. 转换为原始图片坐标: + orig_x = img_x × scale_x + orig_y = img_y × scale_y + 6. 边界检查,确保坐标在有效范围内 + + Args: + canvas_pos: 画布坐标 + + Returns: + QPoint: 原始图片坐标,如果不在图片范围内则返回 None + """ + if self._image is None or self._image.isNull(): + return None + + display_rect = self.get_display_rect() + if display_rect is None: + return None + + disp_x, disp_y, disp_w, disp_h = display_rect + + # 将画布坐标转换为图片坐标 + # 首先计算在显示区域内的相对位置 + img_x = canvas_pos.x() - disp_x + img_y = canvas_pos.y() - disp_y + + # 检查坐标是否在图片显示范围内 + if 0 <= img_x < disp_w and 0 <= img_y < disp_h: + # 计算在原始图片中的坐标 + # 缩放比例 = 原始图片尺寸 / 显示区域尺寸 + scale_x = self._image.width() / disp_w + scale_y = self._image.height() / disp_h + + # 应用缩放比例 + orig_x = int(img_x * scale_x) + orig_y = int(img_y * scale_y) + + # 确保坐标在原始图片范围内(边界检查) + orig_x = max(0, min(orig_x, self._image.width() - 1)) + orig_y = max(0, min(orig_y, self._image.height() - 1)) + + return QPoint(orig_x, orig_y) + + return None + + def get_display_rect(self) -> Optional[Tuple[int, int, int, int]]: + """计算图片在画布中的显示区域 + + 使用保持比例的缩放算法,将图片完整显示在画布中心。 + + 缩放算法: + 1. 获取画布尺寸和原始图片尺寸 + 2. 计算宽度和高度的缩放比例: + scale_w = 画布宽度 / 图片宽度 + scale_h = 画布高度 / 图片高度 + 3. 选择较小的缩放比例(确保图片完整显示) + 4. 使用 Qt.KeepAspectRatio 模式缩放图片 + 5. 计算居中位置: + x = (画布宽度 - 缩放后宽度) / 2 + y = (画布高度 - 缩放后高度) / 2 + + 算法特点: + - 保持图片宽高比,不会变形 + - 图片完整显示在画布内(不会被裁剪) + - 居中显示,四周留有空白 + - 缩放比例不会超过1.0(不会放大) + + Returns: + tuple: (x, y, width, height) 或 None + """ + if self._original_pixmap is None or self._original_pixmap.isNull(): + return None + + # 计算缩放后的尺寸(保持比例) + # Qt.KeepAspectRatio 会自动选择合适的缩放比例 + # 确保图片完整显示在画布内,不会被裁剪 + scaled_size = self._original_pixmap.size() + scaled_size.scale(self.size(), Qt.AspectRatioMode.KeepAspectRatio) + + # 居中显示 + # 计算水平和垂直方向的偏移量,使图片居中 + x = (self.width() - scaled_size.width()) // 2 + y = (self.height() - scaled_size.height()) // 2 + + return x, y, scaled_size.width(), scaled_size.height() + + def get_image_display_rect(self) -> Optional[Tuple[int, int, int, int]]: + """获取图片在画布中的显示区域(供子组件使用) + + Returns: + tuple: (x, y, width, height) 或 None + """ + return self.get_display_rect() + + def paintEvent(self, event) -> None: + """绘制事件""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + # 绘制背景 + painter.fillRect(self.rect(), QColor(42, 42, 42)) + + # 绘制图片(使用原始高分辨率图片,实时缩放显示) + if self._original_pixmap and not self._original_pixmap.isNull(): + display_rect = self.get_display_rect() + if display_rect: + x, y, w, h = display_rect + target_rect = QRect(x, y, w, h) + painter.drawPixmap(target_rect, self._original_pixmap, self._original_pixmap.rect()) + + # 子类可以在此绘制额外的内容 + self._draw_overlay(painter, display_rect) + else: + # 没有图片时显示提示文字 + painter.setPen(QColor(150, 150, 150)) + font = QFont() + font.setPointSize(14) + painter.setFont(font) + text = "点击导入图片" + text_rect = painter.boundingRect(self.rect(), Qt.AlignmentFlag.AlignCenter, text) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + + def _draw_overlay(self, painter: QPainter, display_rect: Tuple[int, int, int, int]) -> None: + """绘制叠加内容 + + Args: + painter: QPainter 对象 + display_rect: 图片显示区域 (x, y, w, h) + + 子类可以重写此方法以绘制额外的内容 + """ + pass + + def mousePressEvent(self, event) -> None: + """鼠标点击事件""" + if event.button() == Qt.MouseButton.LeftButton: + # 如果没有图片,点击打开文件对话框 + if self._original_pixmap is None or self._original_pixmap.isNull(): + self.open_image_requested.emit() + event.accept() + + def resizeEvent(self, event) -> None: + """窗口大小改变时重新调整图片""" + super().resizeEvent(event) + if self._image and not self._image.isNull(): + # 窗口大小改变时,更新取色点位置并重新提取数据 + self.update_picker_positions() + self.extract_all() + self.update() + + def contextMenuEvent(self, event) -> None: + """右键菜单事件""" + # 只有在有图片时才显示右键菜单 + if self._original_pixmap is None or self._original_pixmap.isNull(): + return + + menu = RoundMenu("") + + change_action = Action(FluentIcon.PHOTO, "更换图片") + change_action.triggered.connect(self.change_image_requested.emit) + menu.addAction(change_action) + + clear_action = Action(FluentIcon.DELETE, "清空图片") + clear_action.triggered.connect(self.clear_image_requested.emit) + menu.addAction(clear_action) + + menu.exec(event.globalPos()) + + def clear_image(self) -> None: + """清空图片""" + self._original_pixmap = None + self._image = None + + # 重置相对坐标到默认位置 + for i in range(len(self._picker_rel_positions)): + self._picker_rel_positions[i] = QPointF(0.5, 0.5) + + # 恢复光标为手型 + self.setCursor(Qt.CursorShape.PointingHandCursor) + + self.update() + + # 发送图片已清空信号,用于同步到其他面板 + self.image_cleared.emit() + + def get_image(self) -> Optional[QImage]: + """获取当前图片 + + Returns: + QImage: 当前图片对象,如果没有则返回 None + """ + return self._image + + +class ImageCanvas(BaseCanvas): + """图片显示画布,支持取色点拖动""" + + color_picked = Signal(int, tuple) # 信号:索引, RGB颜色 + picker_moved = Signal(int, tuple) # 信号:索引, (rel_x, rel_y) + picker_dragging = Signal(int, bool) # 信号:索引, 是否正在拖动 + + def __init__(self, parent: Optional[QWidget] = None, picker_count: int = 5) -> None: + super().__init__(parent, picker_count) + self.setMouseTracking(True) + + self._pickers: list = [] + self._zoom_viewer: Optional[ZoomViewer] = None + self._active_picker_index: int = -1 + self._loading_indicator: Optional[IndeterminateProgressRing] = None + + # 创建加载指示器 + self._loading_indicator = IndeterminateProgressRing(self) + self._loading_indicator.setFixedSize(64, 64) + self._loading_indicator.hide() + + # 创建放大视图 + self._zoom_viewer = ZoomViewer(self) + + # 创建取色点(初始隐藏) + for i in range(self._picker_count): + picker = ColorPicker(i, self) + picker.position_changed.connect(self.on_picker_moved) + picker.drag_started.connect(self._on_picker_drag_started) + picker.drag_finished.connect(self._on_picker_drag_finished) + picker.hide() # 初始隐藏 + self._pickers.append(picker) + self._picker_positions.append(QPoint(100 + i * 100, 100)) + self._picker_rel_positions.append(QPointF(0.5, 0.5)) # 默认在图片中心 + + self.update_picker_positions() + self._update_loading_indicator_position() + + def _update_loading_indicator_position(self) -> None: + """更新加载指示器位置到中心""" + if self._loading_indicator: + x = (self.width() - self._loading_indicator.width()) // 2 + y = (self.height() - self._loading_indicator.height()) // 2 + self._loading_indicator.move(x, y) + + def set_image(self, image_path: str) -> None: + """异步加载并显示图片""" + # 显示加载指示器 + if self._loading_indicator: + self._loading_indicator.start() + self._loading_indicator.show() + self._update_loading_indicator_position() + + super().set_image(image_path) + + def _on_image_loaded(self, image_data: bytes, width: int, height: int, fmt: str) -> None: + """图片加载完成的回调""" + super()._on_image_loaded(image_data, width, height, fmt) + + # 隐藏加载指示器 + if self._loading_indicator: + self._loading_indicator.stop() + self._loading_indicator.hide() + + # 改变光标为默认 + self.setCursor(Qt.CursorShape.ArrowCursor) + + def _on_image_load_error(self, error_msg: str) -> None: + """图片加载失败的回调""" + # 隐藏加载指示器 + if self._loading_indicator: + self._loading_indicator.stop() + self._loading_indicator.hide() + + # 恢复光标 + self.setCursor(Qt.CursorShape.PointingHandCursor) + + print(f"图片加载失败: {error_msg}") + + def _setup_after_load(self) -> None: + """图片加载完成后的设置""" + if self._original_pixmap and not self._original_pixmap.isNull(): + # 设置放大视图的图片 + if self._zoom_viewer: + self._zoom_viewer.set_image(self._image) + + # 显示取色点 + for picker in self._pickers: + picker.show() + + # 初始化取色点位置 + self._init_picker_positions() + self.update_picker_positions() + self.update() + + # 发送图片加载信号 + if self._pending_image_path: + self.image_loaded.emit(self._pending_image_path) + # 同时发送图片数据信号,用于同步到其他面板 + self.image_data_loaded.emit(self._original_pixmap, self._image) + + # 延迟提取颜色,让UI先响应,用户可以立即切换面板 + QTimer.singleShot(300, self.extract_all) + + def _update_picker_position(self, index: int, canvas_x: int, canvas_y: int) -> None: + """更新单个取色点的位置""" + if index < len(self._pickers): + picker = self._pickers[index] + picker.move(canvas_x - picker.radius, canvas_y - picker.radius) + + def _on_picker_drag_started(self, index: int) -> None: + """取色点开始拖动""" + self._active_picker_index = index + if self._zoom_viewer: + self._zoom_viewer.show() + self._update_zoom_viewer() + self.picker_dragging.emit(index, True) + + def _on_picker_drag_finished(self, index: int) -> None: + """取色点结束拖动""" + if self._zoom_viewer: + self._zoom_viewer.hide() + self._active_picker_index = -1 + self.picker_dragging.emit(index, False) + + def _on_picker_added(self, index: int) -> None: + """添加取色点时的回调""" + picker = ColorPicker(index, self) + picker.position_changed.connect(self.on_picker_moved) + picker.drag_started.connect(self._on_picker_drag_started) + picker.drag_finished.connect(self._on_picker_drag_finished) + # 如果有图片,显示取色点 + if self._image and not self._image.isNull(): + picker.show() + else: + picker.hide() + self._pickers.append(picker) + + def _on_picker_removed(self, index: int) -> None: + """移除取色点时的回调""" + if index < len(self._pickers): + picker = self._pickers.pop() + picker.deleteLater() + + def _update_zoom_viewer(self) -> None: + """更新放大视图的位置和内容""" + if self._active_picker_index < 0 or self._image is None or not self._zoom_viewer: + return + + picker_pos = self._picker_positions[self._active_picker_index] + + # 更新放大视图位置 + self._zoom_viewer.update_position(picker_pos) + + # 计算原始图片中的坐标 + image_pos = self.canvas_to_image_pos(picker_pos) + if image_pos: + self._zoom_viewer.set_center_position(image_pos) + + def extract_at(self, index: int) -> None: + """提取指定取色点的颜色""" + if self._image is None or self._image.isNull(): + return + + pos = self._picker_positions[index] + image_pos = self.canvas_to_image_pos(pos) + + if image_pos: + # 获取像素颜色 + color = self._image.pixelColor(image_pos.x(), image_pos.y()) + rgb = (color.red(), color.green(), color.blue()) + + # 更新取色点显示的颜色 + if index < len(self._pickers): + self._pickers[index].set_color(color) + + # 发送信号 + self.color_picked.emit(index, rgb) + + # 更新放大视图 + self._update_zoom_viewer() + + def extract_all(self) -> None: + """提取所有取色点的颜色""" + for i in range(len(self._pickers)): + self.extract_at(i) + + def resizeEvent(self, event) -> None: + """窗口大小改变时重新调整图片""" + super().resizeEvent(event) + self._update_loading_indicator_position() + + def clear_image(self) -> None: + """清空图片""" + super().clear_image() + + # 隐藏取色点和放大视图 + for picker in self._pickers: + picker.hide() + if self._zoom_viewer: + self._zoom_viewer.hide() + + +class LuminanceCanvas(BaseCanvas): + """明度提取画布,支持取色点拖动和区域标注""" + + luminance_picked = Signal(int, str) # 信号:索引, 区域编号 + picker_dragging = Signal(int, bool) # 信号:索引, 是否正在拖动 + + def __init__(self, parent: Optional[QWidget] = None, picker_count: int = 5) -> None: + super().__init__(parent, picker_count) + + self._pickers: List[ColorPicker] = [] + self._picker_zones: List[str] = [] # 存储每个取色器的区域编号 + + # Zone高亮相关 + self._highlighted_zone: int = -1 # 当前高亮显示的Zone (-1表示无) + self._zone_highlight_pixmap: Optional[QPixmap] = None # 高亮遮罩缓存 + + # Zone高亮颜色配置 (Zone 0-7) + self._zone_highlight_colors: List[QColor] = [ + QColor(0, 102, 255, 100), # Zone 0: 深蓝色 (极暗) + QColor(0, 128, 255, 100), # Zone 1: 蓝色 (暗) + QColor(0, 153, 255, 100), # Zone 2: 浅蓝色 (偏暗) + QColor(0, 204, 102, 100), # Zone 3: 绿色 (中灰) + QColor(102, 255, 102, 100), # Zone 4: 浅绿色 (偏亮) + QColor(255, 204, 0, 100), # Zone 5: 黄色 (亮) + QColor(255, 128, 0, 100), # Zone 6: 橙色 (很亮) + QColor(255, 51, 102, 100), # Zone 7: 红色 (极亮) + ] + + # 创建取色点(初始隐藏) + for i in range(self._picker_count): + picker = ColorPicker(i, self) + picker.position_changed.connect(self.on_picker_moved) + picker.drag_started.connect(self._on_picker_drag_started) + picker.drag_finished.connect(self._on_picker_drag_finished) + picker.hide() # 初始隐藏 + self._pickers.append(picker) + self._picker_positions.append(QPoint(100 + i * 100, 100)) + self._picker_rel_positions.append(QPointF(0.5, 0.5)) # 默认在图片中心 + self._picker_zones.append("0-1") + + self.update_picker_positions() + + def _on_image_load_error(self, error_msg: str) -> None: + """图片加载失败的回调""" + print(f"明度面板图片加载失败: {error_msg}") + + def _setup_after_load(self) -> None: + """图片加载完成后的设置""" + if self._original_pixmap and not self._original_pixmap.isNull(): + # 显示取色点 + for picker in self._pickers: + picker.show() + + # 改变光标为默认 + self.setCursor(Qt.CursorShape.ArrowCursor) + + # 初始化取色点位置 + self._init_picker_positions() + self.update_picker_positions() + self.update() + + # 延迟提取区域,让UI先响应,用户可以立即切换面板 + QTimer.singleShot(300, self.extract_all) + + def _update_picker_position(self, index: int, canvas_x: int, canvas_y: int) -> None: + """更新单个取色点的位置""" + if index < len(self._pickers): + picker = self._pickers[index] + picker.move(canvas_x - picker.radius, canvas_y - picker.radius) + + def _on_picker_drag_started(self, index: int) -> None: + """取色点开始拖动""" + if index < len(self._pickers): + picker = self._pickers[index] + picker.set_active(True) + self.picker_dragging.emit(index, True) + + def _on_picker_drag_finished(self, index: int) -> None: + """取色点结束拖动""" + if index < len(self._pickers): + picker = self._pickers[index] + picker.set_active(False) + self.picker_dragging.emit(index, False) + + def _on_picker_added(self, index: int) -> None: + """添加取色点时的回调""" + picker = ColorPicker(index, self) + picker.position_changed.connect(self.on_picker_moved) + picker.drag_started.connect(self._on_picker_drag_started) + picker.drag_finished.connect(self._on_picker_drag_finished) + # 如果有图片,显示取色点 + if self._image and not self._image.isNull(): + picker.show() + else: + picker.hide() + self._pickers.append(picker) + self._picker_zones.append("0-1") + + def _on_picker_removed(self, index: int) -> None: + """移除取色点时的回调""" + if index < len(self._pickers): + picker = self._pickers.pop() + picker.deleteLater() + self._picker_zones.pop() + + def extract_at(self, index: int) -> None: + """提取指定取色点的区域编号""" + if self._image is None or self._image.isNull(): + return + + pos = self._picker_positions[index] + image_pos = self.canvas_to_image_pos(pos) + + if image_pos: + # 获取像素颜色 + color = self._image.pixelColor(image_pos.x(), image_pos.y()) + luminance = get_luminance(color.red(), color.green(), color.blue()) + zone = get_zone(luminance) + + # 更新区域编号 + self._picker_zones[index] = zone + + # 发送信号 + self.luminance_picked.emit(index, zone) + + def extract_all(self) -> None: + """提取所有取色点的区域编号""" + for i in range(len(self._pickers)): + self.extract_at(i) + + def _draw_overlay(self, painter: QPainter, display_rect: Tuple[int, int, int, int]) -> None: + """绘制叠加内容""" + # 绘制Zone高亮遮罩(在图片上方) + self._draw_zone_highlight(painter, display_rect) + + # 绘制区域标注 + self._draw_zone_labels(painter, display_rect) + + def _draw_zone_labels(self, painter: QPainter, display_rect: Tuple[int, int, int, int]) -> None: + """绘制区域标注(白色小方框+黑色文字)""" + disp_x, disp_y, disp_w, disp_h = display_rect + + font = QFont() + font.setPointSize(9) + font.setBold(True) + painter.setFont(font) + + for i, pos in enumerate(self._picker_positions): + # 检查取色器是否在图片显示区域内 + img_x = pos.x() - disp_x + img_y = pos.y() - disp_y + + if 0 <= img_x < disp_w and 0 <= img_y < disp_h: + zone = self._picker_zones[i] + + # 计算文字尺寸 + text_rect = painter.boundingRect(QRect(), Qt.AlignmentFlag.AlignCenter, zone) + text_width = text_rect.width() + text_height = text_rect.height() + + # 方框尺寸(稍大于文字) + box_width = text_width + 8 + box_height = text_height + 4 + + # 方框位置(取色器上方) + box_x = pos.x() - box_width // 2 + box_y = pos.y() - 35 # 取色器上方35像素 + + # 绘制白色填充方框 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(255, 255, 255)) + painter.drawRect(box_x, box_y, box_width, box_height) + + # 绘制黑色文字 + painter.setPen(QColor(0, 0, 0)) + text_x = box_x + (box_width - text_width) // 2 + text_y = box_y + (box_height - text_height) // 2 + painter.drawText(text_x, text_y + text_height - 2, zone) + + def clear_image(self) -> None: + """清空图片""" + super().clear_image() + + # 隐藏取色点 + for picker in self._pickers: + picker.hide() + + # 重置区域编号 + self._picker_zones = ["0-1"] * len(self._pickers) + + def get_picker_zones(self) -> List[str]: + """获取所有取色器的区域编号 + + Returns: + list: 区域编号列表 + """ + return self._picker_zones.copy() + + def highlight_zone(self, zone: int) -> None: + """高亮显示指定Zone的亮度范围 + + Args: + zone: Zone编号 (0-7) + """ + if not (0 <= zone <= 7): + return + + if self._image is None or self._image.isNull(): + return + + self._highlighted_zone = zone + self._zone_highlight_pixmap = None # 清除缓存,重新生成 + self.update() + + def clear_zone_highlight(self) -> None: + """清除Zone高亮显示""" + self._highlighted_zone = -1 + self._zone_highlight_pixmap = None + self.update() + + def _generate_zone_highlight_pixmap(self, display_rect: Tuple[int, int, int, int]) -> Optional[QPixmap]: + """生成Zone高亮遮罩图 + + Args: + display_rect: 图片显示区域 (x, y, w, h) + + Returns: + QPixmap: 高亮遮罩图 + """ + if self._image is None or self._image.isNull(): + return None + + disp_x, disp_y, disp_w, disp_h = display_rect + + # 创建透明遮罩图 + highlight_pixmap = QPixmap(self.size()) + highlight_pixmap.fill(Qt.GlobalColor.transparent) + + painter = QPainter(highlight_pixmap) + painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) + + # 获取当前Zone的颜色 + zone_color = self._zone_highlight_colors[self._highlighted_zone] + + # 计算亮度范围 + min_lum = self._highlighted_zone * 32 + max_lum = (self._highlighted_zone + 1) * 32 - 1 + + # 计算缩放比例 + scale_x = self._image.width() / disp_w + scale_y = self._image.height() / disp_h + + # 采样步长(性能优化) + sample_step = 4 + + # 遍历显示区域的像素 + for dy in range(0, disp_h, sample_step): + for dx in range(0, disp_w, sample_step): + # 计算对应的原始图片坐标 + img_x = int(dx * scale_x) + img_y = int(dy * scale_y) + + # 边界检查 + img_x = min(img_x, self._image.width() - 1) + img_y = min(img_y, self._image.height() - 1) + + # 获取像素颜色并计算亮度 + color = self._image.pixelColor(img_x, img_y) + luminance = get_luminance(color.red(), color.green(), color.blue()) + + # 如果亮度在当前Zone范围内,绘制遮罩 + if min_lum <= luminance <= max_lum: + painter.fillRect( + disp_x + dx, + disp_y + dy, + sample_step, + sample_step, + zone_color + ) + + painter.end() + return highlight_pixmap + + def _draw_zone_highlight(self, painter: QPainter, display_rect: Tuple[int, int, int, int]) -> None: + """绘制Zone高亮遮罩 + + Args: + painter: QPainter对象 + display_rect: 图片显示区域 (x, y, w, h) + """ + if self._highlighted_zone < 0: + return + + # 如果缓存不存在,生成遮罩图 + if self._zone_highlight_pixmap is None: + self._zone_highlight_pixmap = self._generate_zone_highlight_pixmap(display_rect) + + # 绘制遮罩图 + if self._zone_highlight_pixmap: + painter.drawPixmap(0, 0, self._zone_highlight_pixmap) + + # 绘制Zone信息提示 + self._draw_zone_highlight_info(painter, display_rect) + + def _draw_zone_highlight_info(self, painter: QPainter, display_rect: Tuple[int, int, int, int]) -> None: + """绘制Zone高亮信息提示 + + Args: + painter: QPainter对象 + display_rect: 图片显示区域 (x, y, w, h) + """ + if self._highlighted_zone < 0: + return + + disp_x, disp_y, disp_w, disp_h = display_rect + + # 准备文字 + zone_labels = ["0-1", "1-2", "2-3", "3-4", "4-5", "5-6", "6-7", "7-8"] + zone_names = [ + "黑色", "阴影", "暗部", "中间调", + "亮部", "高光", "白色", "极白" + ] + label = zone_labels[self._highlighted_zone] + name = zone_names[self._highlighted_zone] + + # 计算亮度范围 + min_lum = self._highlighted_zone * 32 + max_lum = (self._highlighted_zone + 1) * 32 - 1 + + text = f"{label} ({name}) | 亮度: {min_lum}-{max_lum}" + + # 设置字体 + font = QFont() + font.setPointSize(11) + font.setBold(True) + painter.setFont(font) + + # 计算文字尺寸 + text_rect = painter.boundingRect(QRect(), Qt.AlignmentFlag.AlignLeft, text) + text_width = text_rect.width() + text_height = text_rect.height() + + # 背景框位置和尺寸 + padding = 10 + box_width = text_width + padding * 2 + box_height = text_height + padding * 2 + box_x = disp_x + (disp_w - box_width) // 2 + box_y = disp_y + 20 + + # 绘制半透明背景框 + bg_color = QColor(0, 0, 0, 180) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(bg_color) + painter.drawRoundedRect(box_x, box_y, box_width, box_height, 6, 6) + + # 绘制文字 + text_color = self._zone_highlight_colors[self._highlighted_zone] + # 使用不透明版本的颜色 + text_color.setAlpha(255) + painter.setPen(text_color) + painter.drawText( + box_x + padding, + box_y + padding + text_height - 4, + text + ) + + +__all__ = [ + 'ImageLoader', + 'BaseCanvas', + 'ImageCanvas', + 'LuminanceCanvas', +] \ No newline at end of file diff --git a/ui/cards.py b/ui/cards.py new file mode 100644 index 0000000000000000000000000000000000000000..e78f283317fe5ac5448ca51c3448afdec8025be2 --- /dev/null +++ b/ui/cards.py @@ -0,0 +1,592 @@ +# 第三方库导入 +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QFont, QPainter +from PySide6.QtWidgets import QApplication, QHBoxLayout, QLabel, QVBoxLayout, QWidget +from qfluentwidgets import FluentIcon, InfoBar, InfoBarPosition, PushButton, ToolButton, isDarkTheme + + +class BaseCard(QWidget): + """卡片基类,提供统一的卡片接口 + + 子类需要实现: + - setup_ui(): 设置界面 + - clear(): 清空显示 + """ + + def __init__(self, index: int, parent=None): + super().__init__(parent) + self.index = index + self.setup_ui() + + def setup_ui(self): + """设置界面(子类必须实现)""" + raise NotImplementedError("子类必须实现 setup_ui 方法") + + def clear(self): + """清空显示(子类必须实现)""" + raise NotImplementedError("子类必须实现 clear 方法") + + +class BaseCardPanel(QWidget): + """卡片面板基类,提供统一的卡片管理功能 + + 功能: + - 卡片列表管理 + - 卡片数量控制(2-5个) + - 批量清空卡片 + """ + + def __init__(self, parent=None, card_count: int = 5): + super().__init__(parent) + self._card_count = card_count + self.cards = [] + self.setup_ui() + self._create_initial_cards() + + def _create_initial_cards(self): + """创建初始卡片""" + for i in range(self._card_count): + card = self._create_card(i) + self.cards.append(card) + self.layout().addWidget(card) + + def setup_ui(self): + """设置界面""" + layout = QHBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(15) + + def set_card_count(self, count: int): + """设置卡片数量 + + Args: + count: 卡片数量 (2-5) + """ + if count < 2 or count > 5: + return + + if count == self._card_count: + return + + old_count = self._card_count + self._card_count = count + + layout = self.layout() + + if count > old_count: + self._add_cards(old_count, count) + else: + self._remove_cards(old_count, count) + + def _add_cards(self, old_count: int, new_count: int): + """增加卡片(子类重写)""" + for i in range(old_count, new_count): + card = self._create_card(i) + self.cards.append(card) + self.layout().addWidget(card) + + def _remove_cards(self, old_count: int, new_count: int): + """减少卡片""" + for i in range(old_count - 1, new_count - 1, -1): + card = self.cards.pop() + self.layout().removeWidget(card) + card.deleteLater() + + def _create_card(self, index: int): + """创建单个卡片(子类必须实现) + + Args: + index: 卡片索引 + + Returns: + BaseCard: 卡片实例 + """ + raise NotImplementedError("子类必须实现 _create_card 方法") + + def clear_all(self): + """清空所有卡片""" + for card in self.cards: + card.clear() + + +# 色彩模式配置:模式名称 -> (显示名称, 标签列表, 单位列表, 格式化函数) +COLOR_MODE_CONFIG = { + 'HSB': ( + 'HSB', + ['H:', 'S:', 'B:'], + ['°', '%', '%'], + lambda values: [f"{values[0]}°", f"{values[1]}%", f"{values[2]}%"] + ), + 'LAB': ( + 'LAB', + ['L:', 'A:', 'B:'], + ['', '', ''], + lambda values: [str(values[0]), str(values[1]), str(values[2])] + ), + 'HSL': ( + 'HSL', + ['H:', 'S:', 'L:'], + ['°', '%', '%'], + lambda values: [f"{values[0]}°", f"{values[1]}%", f"{values[2]}%"] + ), + 'CMYK': ( + 'CMYK', + ['C:', 'M:', 'Y:', 'K:'], + ['%', '%', '%', '%'], + lambda values: [f"{values[0]}%", f"{values[1]}%", f"{values[2]}%", f"{values[3]}%"] + ), + 'RGB': ( + 'RGB', + ['R:', 'G:', 'B:'], + ['', '', ''], + lambda values: [str(values[0]), str(values[1]), str(values[2])] + ) +} + + +def get_text_color(secondary=False): + """获取主题文本颜色""" + if isDarkTheme(): + return QColor(160, 160, 160) if secondary else QColor(255,255,255) + else: + return QColor(120, 120, 120) if secondary else QColor(40, 40, 40) + + +def get_placeholder_color(): + """获取占位符颜色(空色块背景)""" + if isDarkTheme(): + return QColor(60, 60, 60) + else: + return QColor(204, 204, 204) + + +def get_border_color(): + """获取边框颜色""" + if isDarkTheme(): + return QColor(80, 80, 80) + else: + return QColor(221, 221, 221) + + +class ColorValueLabel(QWidget): + """显示单个颜色值的标签""" + def __init__(self, label_text, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(5, 2, 5, 2) + layout.setSpacing(5) + + self.label = QLabel(label_text) + self.value = QLabel("--") + self._update_styles() + + layout.addWidget(self.label) + layout.addWidget(self.value) + layout.addStretch() + + def _update_styles(self): + """更新样式以适配主题""" + secondary_color = get_text_color(secondary=True) + primary_color = get_text_color(secondary=False) + + self.label.setStyleSheet( + f"color: {secondary_color.name()}; font-size: 11px;" + ) + self.value.setStyleSheet( + f"color: {primary_color.name()}; font-size: 12px; font-weight: bold;" + ) + + def set_value(self, value): + self.value.setText(str(value)) + + +class ColorModeContainer(QWidget): + """显示单个色彩模式的容器""" + def __init__(self, mode='HSB', parent=None): + super().__init__(parent) + self._mode = mode + self._labels = [] + self.setup_ui() + + def setup_ui(self): + """设置界面""" + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + + # 根据模式创建标签 + config = COLOR_MODE_CONFIG.get(self._mode, COLOR_MODE_CONFIG['HSB']) + labels_text = config[1] + self._labels = [] + for text in labels_text: + label = ColorValueLabel(text) + self._labels.append(label) + layout.addWidget(label) + + def set_mode(self, mode): + """设置色彩模式""" + if self._mode == mode: + return + self._mode = mode + + # 清除现有标签 + layout = self.layout() + while layout.count(): + item = layout.takeAt(0) + if item.widget(): + item.widget().deleteLater() + + # 重新创建标签 + config = COLOR_MODE_CONFIG.get(mode, COLOR_MODE_CONFIG['HSB']) + labels_text = config[1] + self._labels = [] + for text in labels_text: + label = ColorValueLabel(text) + self._labels.append(label) + layout.addWidget(label) + + def update_values(self, color_info): + """更新颜色值显示""" + mode_key = self._mode.lower() + if mode_key not in color_info: + return + + values = color_info[mode_key] + config = COLOR_MODE_CONFIG.get(self._mode, COLOR_MODE_CONFIG['HSB']) + format_func = config[3] + formatted_values = format_func(values) + + for i, label in enumerate(self._labels): + if i < len(formatted_values): + label.set_value(formatted_values[i]) + else: + label.set_value("--") + + def clear_values(self): + """清空所有值""" + for label in self._labels: + label.set_value("--") + + +class ColorCard(BaseCard): + """单个色卡组件""" + def __init__(self, index, parent=None): + self._hex_value = "--" + self._color_modes = ['HSB', 'LAB'] + self._current_color_info = None + super().__init__(index, parent) + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(5) + + # 颜色块 + self.color_block = QWidget() + self.color_block.setFixedHeight(80) + self._update_placeholder_style() + layout.addWidget(self.color_block) + + # 数值区域(两列布局) + values_container = QWidget() + values_layout = QHBoxLayout(values_container) + values_layout.setContentsMargins(0, 0, 0, 0) + values_layout.setSpacing(10) + + # 第一列色彩模式 + self.mode_container_1 = ColorModeContainer(self._color_modes[0]) + values_layout.addWidget(self.mode_container_1) + + # 第二列色彩模式 + self.mode_container_2 = ColorModeContainer(self._color_modes[1]) + values_layout.addWidget(self.mode_container_2) + + layout.addWidget(values_container) + + # 16进制颜色值显示区域 + self.hex_container = QWidget() + hex_layout = QHBoxLayout(self.hex_container) + hex_layout.setContentsMargins(0, 5, 0, 0) + hex_layout.setSpacing(5) + + # 16进制值显示按钮 + self.hex_button = PushButton("--") + self.hex_button.setFixedHeight(28) + self.hex_button.setEnabled(False) + self._update_hex_button_style() + + # 复制按钮 + self.copy_button = ToolButton(FluentIcon.COPY) + self.copy_button.setFixedSize(28, 28) + self.copy_button.setEnabled(False) + self.copy_button.clicked.connect(self._copy_hex_to_clipboard) + + hex_layout.addWidget(self.hex_button, stretch=1) + hex_layout.addWidget(self.copy_button) + + layout.addWidget(self.hex_container) + layout.addStretch() + + def _update_placeholder_style(self): + """更新占位符样式""" + placeholder_color = get_placeholder_color() + self.color_block.setStyleSheet( + f"background-color: {placeholder_color.name()}; border-radius: 4px;" + ) + + def _update_hex_button_style(self): + """更新16进制按钮样式""" + primary_color = get_text_color(secondary=False) + self.hex_button.setStyleSheet( + f""" + PushButton {{ + font-size: 12px; + font-weight: bold; + color: {primary_color.name()}; + background-color: transparent; + border: 1px solid {get_border_color().name()}; + border-radius: 4px; + padding: 4px 8px; + }} + PushButton:disabled {{ + color: {get_text_color(secondary=True).name()}; + background-color: transparent; + }} + """ + ) + + def _copy_hex_to_clipboard(self): + """复制16进制颜色值到剪贴板""" + if self._hex_value and self._hex_value != "--": + clipboard = QApplication.clipboard() + clipboard.setText(self._hex_value) + # 显示复制成功提示 + InfoBar.success( + title="已复制", + content=f"颜色值 {self._hex_value} 已复制到剪贴板", + orient=Qt.Orientation.Horizontal, + isClosable=True, + position=InfoBarPosition.TOP, + duration=2000, + parent=self.window() + ) + + def set_color_modes(self, modes): + """设置显示的色彩模式""" + if len(modes) < 2: + return + + self._color_modes = [modes[0], modes[1]] + self.mode_container_1.set_mode(modes[0]) + self.mode_container_2.set_mode(modes[1]) + + # 如果有当前颜色信息,更新显示 + if self._current_color_info: + self.update_color_display() + + def set_color(self, color_info): + """设置颜色信息""" + self._current_color_info = color_info + + # 更新颜色块 + r, g, b = color_info['rgb'] + color_str = f"rgb({r}, {g}, {b})" + border_color = get_border_color() + self.color_block.setStyleSheet( + f"background-color: {color_str}; border-radius: 4px; border: 1px solid {border_color.name()};" + ) + + # 更新色彩模式值 + self.update_color_display() + + # 更新16进制值 + self._hex_value = color_info['hex'] + self.hex_button.setText(self._hex_value) + self.hex_button.setEnabled(True) + self.copy_button.setEnabled(True) + + def update_color_display(self): + """根据当前模式更新颜色值显示""" + if not self._current_color_info: + return + + self.mode_container_1.update_values(self._current_color_info) + self.mode_container_2.update_values(self._current_color_info) + + def clear(self): + """清空颜色,恢复默认状态""" + self._current_color_info = None + + # 重置颜色块 + self._update_placeholder_style() + + # 重置所有值 + self.mode_container_1.clear_values() + self.mode_container_2.clear_values() + + # 重置16进制值 + self._hex_value = "--" + self.hex_button.setText("--") + self.hex_button.setEnabled(False) + self.copy_button.setEnabled(False) + + def set_hex_visible(self, visible): + """设置16进制显示区域的可见性""" + self.hex_container.setVisible(visible) + + +class ColorCardPanel(BaseCardPanel): + """色卡面板(包含多个色卡)""" + def __init__(self, parent=None, card_count=5): + self._hex_visible = True + self._color_modes = ['HSB', 'LAB'] + super().__init__(parent, card_count) + + def _create_card(self, index): + """创建色卡实例""" + card = ColorCard(index) + card.set_color_modes(self._color_modes) + card.set_hex_visible(self._hex_visible) + return card + + def set_color_modes(self, modes): + """设置显示的色彩模式""" + if len(modes) < 2: + return + + self._color_modes = [modes[0], modes[1]] + for card in self.cards: + card.set_color_modes(self._color_modes) + + def update_color(self, index, color_info): + """更新指定索引的颜色""" + if 0 <= index < len(self.cards): + self.cards[index].set_color(color_info) + + def set_hex_visible(self, visible): + """设置是否显示16进制颜色值""" + self._hex_visible = visible + for card in self.cards: + card.set_hex_visible(visible) + + def is_hex_visible(self): + """获取16进制颜色值显示状态""" + return self._hex_visible + + +def get_zone_background_color(): + """获取Zone框背景颜色""" + if isDarkTheme(): + return QColor(70, 70, 70) + else: + return QColor(255, 255, 255) + + +def get_zone_text_color(): + """获取Zone框文字颜色""" + if isDarkTheme(): + return QColor(255, 255, 255) + else: + return QColor(0, 0, 0) + + +def get_secondary_text_color(): + """获取次要文字颜色""" + if isDarkTheme(): + return QColor(160, 160, 160) + else: + return QColor(120, 120, 120) + + +class ZoneValueLabel(QWidget): + """显示Zone值的标签 - 主题适配背景框 + 主题适配文字""" + def __init__(self, parent=None): + super().__init__(parent) + self.setFixedSize(50, 30) + self._zone = -1 + self._luminance = 0 + + def set_zone(self, zone: int, luminance: int = 0): + """设置Zone值""" + self._zone = zone + self._luminance = luminance + self.update() + + def clear(self): + """清空显示""" + self._zone = -1 + self._luminance = 0 + self.update() + + def get_zone_label(self) -> str: + """获取Zone显示标签""" + if self._zone < 0: + return "--" + return str(self._zone) + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 主题适配背景框 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(get_zone_background_color()) + painter.drawRoundedRect(0, 0, self.width(), self.height(), 4, 4) + + # 主题适配文字 + painter.setPen(get_zone_text_color()) + font = QFont() + font.setPointSize(12) + font.setBold(True) + painter.setFont(font) + + label = self.get_zone_label() + painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, label) + + +class LuminanceCard(BaseCard): + """单个明度信息卡 - 简化版,只显示Zone""" + def __init__(self, index, parent=None): + super().__init__(index, parent) + + def setup_ui(self): + layout = QVBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(10) + layout.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # Zone显示框 + self.zone_label = ZoneValueLabel() + layout.addWidget(self.zone_label, alignment=Qt.AlignmentFlag.AlignCenter) + + # 索引标签 + index_label = QLabel(f"#{self.index + 1}") + secondary_color = get_secondary_text_color() + index_label.setStyleSheet(f"color: {secondary_color.name()}; font-size: 11px;") + index_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + layout.addWidget(index_label) + + layout.addStretch() + + def set_zone(self, zone: int, luminance: int = 0): + """设置Zone信息""" + self.zone_label.set_zone(zone, luminance) + + def clear(self): + """清空显示""" + self.zone_label.clear() + + +class LuminanceCardPanel(BaseCardPanel): + """明度信息卡面板(包含多个Zone卡)""" + def __init__(self, parent=None, card_count=5): + super().__init__(parent, card_count) + + def _create_card(self, index): + """创建明度卡实例""" + return LuminanceCard(index) + + def update_zone(self, index: int, zone: int, luminance: int = 0): + """更新指定索引的Zone""" + if 0 <= index < len(self.cards): + self.cards[index].set_zone(zone, luminance) diff --git a/widgets/color_picker.py b/ui/color_picker.py similarity index 72% rename from widgets/color_picker.py rename to ui/color_picker.py index e0041ed12f7334be5eba975ec76ed7d9738b6591..346c162d533f85197c937546ebec477c159d7ccc 100644 --- a/widgets/color_picker.py +++ b/ui/color_picker.py @@ -1,6 +1,7 @@ +# 第三方库导入 +from PySide6.QtCore import QPoint, Qt, Signal +from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import QWidget -from PySide6.QtCore import Qt, QPoint, Signal -from PySide6.QtGui import QPainter, QColor, QPen class ColorPicker(QWidget): @@ -82,10 +83,28 @@ class ColorPicker(QWidget): # 计算新位置 new_pos = self.mapToParent(event.pos()) - self._drag_offset - # 限制在父控件范围内 - parent_rect = self.parent().rect() - new_pos.setX(max(0, min(new_pos.x(), parent_rect.width() - self.width()))) - new_pos.setY(max(0, min(new_pos.y(), parent_rect.height() - self.height()))) + # 获取父控件(ImageCanvas)的图片显示区域 + parent = self.parent() + if hasattr(parent, 'get_image_display_rect'): + display_rect = parent.get_image_display_rect() + if display_rect: + disp_x, disp_y, disp_w, disp_h = display_rect + # 限制取色点中心在图片显示区域内 + center_x = new_pos.x() + self.radius + center_y = new_pos.y() + self.radius + + # 限制中心点在图片区域内 + center_x = max(disp_x, min(center_x, disp_x + disp_w)) + center_y = max(disp_y, min(center_y, disp_y + disp_h)) + + # 转换回左上角坐标 + new_pos.setX(center_x - self.radius) + new_pos.setY(center_y - self.radius) + else: + # 回退:限制在父控件范围内 + parent_rect = parent.rect() + new_pos.setX(max(0, min(new_pos.x(), parent_rect.width() - self.width()))) + new_pos.setY(max(0, min(new_pos.y(), parent_rect.height() - self.height()))) self.move(new_pos) self.position_changed.emit(self.index, new_pos + QPoint(self.radius, self.radius)) diff --git a/ui/color_wheel.py b/ui/color_wheel.py new file mode 100644 index 0000000000000000000000000000000000000000..1231d028d4279146ce34ce558cd714f55546cbf8 --- /dev/null +++ b/ui/color_wheel.py @@ -0,0 +1,293 @@ +# 第三方库导入 +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QPainter, QPen, QPixmap +from PySide6.QtWidgets import QWidget +from qfluentwidgets import isDarkTheme + +# 项目模块导入 +from core import rgb_to_hsb + + +class HSBColorWheel(QWidget): + """HSB色环组件 - 显示采样点在HSB色彩空间中的位置(不可编辑)""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumSize(180, 180) + self.setMaximumSize(300, 300) + + self._sample_colors = [] # 采样点颜色列表 [(r, g, b), ...] + self._wheel_radius = 0 # 色环半径(动态计算) + self._center_x = 0 # 中心X坐标 + self._center_y = 0 # 中心Y坐标 + + # 缓存 + self._wheel_cache = None # 色环背景缓存 + self._cache_valid = False # 缓存是否有效 + self._cached_theme = None # 缓存时的主题 + + def set_sample_colors(self, colors): + """设置采样点颜色 + + Args: + colors: 颜色列表,每个元素为 (r, g, b) 元组 + """ + self._sample_colors = colors if colors else [] + self.update() + + def update_sample_point(self, index, rgb): + """更新指定索引的采样点颜色 + + Args: + index: 采样点索引 + rgb: (r, g, b) 元组 + """ + if index < 0: + return + + # 确保列表足够长 + while len(self._sample_colors) <= index: + self._sample_colors.append((128, 128, 128)) # 默认灰色 + + self._sample_colors[index] = rgb + self.update() # 只重绘采样点,背景使用缓存 + + def clear_sample_points(self): + """清除所有采样点""" + self._sample_colors = [] + self.update() + + def set_sample_count(self, count): + """设置采样点数量 + + Args: + count: 采样点数量 + """ + # 调整采样点列表长度 + current_count = len(self._sample_colors) + if count > current_count: + # 增加采样点,使用默认灰色 + for _ in range(count - current_count): + self._sample_colors.append((128, 128, 128)) + elif count < current_count: + # 减少采样点 + self._sample_colors = self._sample_colors[:count] + self.update() + + def _get_theme_colors(self): + """获取主题颜色""" + if isDarkTheme(): + return { + 'bg': QColor(42, 42, 42), + 'border': QColor(80, 80, 80), + 'text': QColor(200, 200, 200), + 'sample_border': QColor(255, 255, 255) + } + else: + return { + 'bg': QColor(240, 240, 240), + 'border': QColor(200, 200, 200), + 'text': QColor(60, 60, 60), + 'sample_border': QColor(255, 255, 255) + } + + def _calculate_wheel_geometry(self): + """计算色环几何参数""" + # 留边距 + margin = 20 + available_size = min(self.width(), self.height()) - margin * 2 + self._wheel_radius = available_size // 2 + self._center_x = self.width() // 2 + self._center_y = self.height() // 2 + + def _hsb_to_position(self, h, s, b): + """将HSB值转换为色环上的位置 + + Args: + h: 色相 (0-360) + s: 饱和度 (0-100) + b: 亮度 (0-100) + + Returns: + (x, y) 坐标 + """ + import math + + # 色相转换为角度(0°在右侧,逆时针增加) + # Qt坐标系:0°在右侧,逆时针为正 + angle_rad = (h * math.pi / 180.0) + + # 饱和度转换为半径(0%在中心,100%在边缘) + # 使用80%的最大半径,留一些边距 + max_radius = self._wheel_radius * 0.85 + radius = (s / 100.0) * max_radius + + # 计算坐标 + x = self._center_x + radius * math.cos(angle_rad) + y = self._center_y - radius * math.sin(angle_rad) + + return int(x), int(y) + + def _invalidate_cache(self): + """使缓存失效""" + self._cache_valid = False + self._wheel_cache = None + + def _generate_wheel_cache(self): + """生成色环背景缓存""" + import math + + # 创建缓存图像 + self._wheel_cache = QPixmap(self.size()) + self._wheel_cache.fill(Qt.GlobalColor.transparent) + + painter = QPainter(self._wheel_cache) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + colors = self._get_theme_colors() + + # 绘制背景 + painter.fillRect(self.rect(), colors['bg']) + + # 计算色环几何参数 + self._calculate_wheel_geometry() + + # 绘制HSB色环背景(优化版本) + # 减少分段数以提高性能 + num_segments = 120 # 从360减少到120,足够平滑 + + for i in range(num_segments): + # 计算当前段的角度(度) + angle_start = i * 360 / num_segments + angle_end = (i + 1) * 360 / num_segments + + # 当前段的色相 + hue = angle_start + + # 绘制从外到内的渐变条(一直到中心) + num_rings = 15 # 从25减少到15,提高性能 + for j in range(num_rings): + # 计算内外半径(从外到内,包括中心) + r_outer = self._wheel_radius * (j + 1) / num_rings + r_inner = self._wheel_radius * j / num_rings + + # 当前环的饱和度(外圈100%,中心0%) + saturation = 100 * (j + 0.5) / num_rings + + # 创建颜色 + color = QColor.fromHsvF(hue / 360.0, saturation / 100.0, 1.0) + + # 绘制扇形段 + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(color) + + # 使用多边形近似扇形 + points = [] + num_arc_points = 4 # 从5减少到4 + + # 外弧 + for k in range(num_arc_points): + t = k / (num_arc_points - 1) + a = (angle_start + t * (angle_end - angle_start)) * math.pi / 180 + points.append(( + self._center_x + r_outer * math.cos(a), + self._center_y - r_outer * math.sin(a) + )) + + # 内弧(反向) + for k in range(num_arc_points): + t = k / (num_arc_points - 1) + a = (angle_end - t * (angle_end - angle_start)) * math.pi / 180 + points.append(( + self._center_x + r_inner * math.cos(a), + self._center_y - r_inner * math.sin(a) + )) + + # 转换为QPoint并绘制 + from PySide6.QtCore import QPoint + qpoints = [QPoint(int(p[0]), int(p[1])) for p in points] + painter.drawPolygon(qpoints) + + # 绘制外边框 + painter.setPen(QPen(colors['border'], 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse( + self._center_x - self._wheel_radius, + self._center_y - self._wheel_radius, + self._wheel_radius * 2, + self._wheel_radius * 2 + ) + + painter.end() + + # 标记缓存有效 + self._cache_valid = True + self._cached_theme = isDarkTheme() + + def paintEvent(self, event): + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 检查是否需要重新生成缓存 + current_theme = isDarkTheme() + if not self._cache_valid or self._cached_theme != current_theme: + self._generate_wheel_cache() + + # 绘制缓存的色环背景 + if self._wheel_cache: + painter.drawPixmap(0, 0, self._wheel_cache) + + # 绘制采样点 + self._draw_sample_points(painter) + + # 绘制标题 + self._draw_title(painter) + + def _draw_sample_points(self, painter): + """绘制采样点""" + if not self._sample_colors: + return + + sample_border_color = self._get_theme_colors()['sample_border'] + + for rgb in self._sample_colors: + r, g, b = rgb + + # 转换为HSB + h, s, v = rgb_to_hsb(r, g, b) + + # 计算位置 + x, y = self._hsb_to_position(h, s, v) + + # 绘制采样点(带白色边框的圆点) + point_radius = 8 + + # 白色外边框 + painter.setPen(QPen(sample_border_color, 3)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(x - point_radius, y - point_radius, + point_radius * 2, point_radius * 2) + + # 填充颜色(使用实际颜色,但确保可见性) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(QColor(r, g, b)) + painter.drawEllipse(x - point_radius + 2, y - point_radius + 2, + (point_radius - 2) * 2, (point_radius - 2) * 2) + + def _draw_title(self, painter): + """绘制标题""" + colors = self._get_theme_colors() + painter.setPen(colors['text']) + + font = painter.font() + font.setPointSize(9) + painter.setFont(font) + + title = "HSB色环" + painter.drawText(10, 20, title) + + def resizeEvent(self, event): + """窗口大小改变时重新计算几何参数""" + super().resizeEvent(event) + self._calculate_wheel_geometry() + self._invalidate_cache() # 使缓存失效,下次绘制时重新生成 diff --git a/ui/histograms.py b/ui/histograms.py new file mode 100644 index 0000000000000000000000000000000000000000..61ae6c6adf9231075e03d379d45e95a22dab24cd --- /dev/null +++ b/ui/histograms.py @@ -0,0 +1,572 @@ +# 第三方库导入 +from typing import List, Optional +from PySide6.QtCore import Qt, Signal +from PySide6.QtGui import QColor, QFont, QLinearGradient, QPainter, QPen +from PySide6.QtWidgets import QWidget + +# 项目模块导入 +from core import calculate_histogram, calculate_rgb_histogram, get_zone_bounds + + +class BaseHistogram(QWidget): + """直方图基类,提供通用的直方图绘制功能 + + 功能: + - 绘制柱状图 + - 支持数据归一化 + - 自定义颜色 + + 子类需要实现: + - _draw_histogram(painter, x, y, width, height): 绘制直方图 + - _draw_custom_overlay(painter, x, y, width, height): 绘制自定义叠加内容 + - _draw_labels(painter, x, y, width, height): 绘制刻度标签 + + 信号: + data_changed: 数据变化时发射(子类可扩展) + """ + + def __init__(self, parent=None): + super().__init__(parent) + self._histogram: List[int] = [] + self._max_count = 0 + + # 绘图边距 + self._margin_left = 35 + self._margin_right = 15 + self._margin_top = 15 + self._margin_bottom = 30 + + # 背景色 + self._background_color = QColor(20, 20, 20) + + def set_data(self, data: List[int]): + """设置直方图数据 + + Args: + data: 长度为 256 的整数列表 + """ + self._histogram = data + self._max_count = max(data) if data else 0 + self.update() + + def clear(self): + """清空数据""" + self._histogram = [] + self._max_count = 0 + self.update() + + def paintEvent(self, event): + """绘制直方图""" + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + + # 绘制背景 + painter.fillRect(self.rect(), self._background_color) + + # 计算绘图区域 + draw_width = self.width() - self._margin_left - self._margin_right + draw_height = self.height() - self._margin_top - self._margin_bottom + + if draw_width <= 0 or draw_height <= 0: + return + + # 绘制直方图 + self._draw_histogram(painter, self._margin_left, self._margin_top, draw_width, draw_height) + + # 绘制自定义叠加内容 + self._draw_custom_overlay(painter, self._margin_left, self._margin_top, draw_width, draw_height) + + # 绘制刻度标签 + self._draw_labels(painter, self._margin_left, self._margin_top, draw_width, draw_height) + + def _draw_histogram(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制直方图(子类必须实现) + + Args: + painter: QPainter 对象 + x: 绘图区域左上角 X 坐标 + y: 绘图区域左上角 Y 坐标 + width: 绘图区域宽度 + height: 绘图区域高度 + """ + raise NotImplementedError("子类必须实现 _draw_histogram 方法") + + def _draw_custom_overlay(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制自定义叠加内容(子类重写) + + Args: + painter: QPainter 对象 + x: 绘图区域左上角 X 坐标 + y: 绘图区域左上角 Y 坐标 + width: 绘图区域宽度 + height: 绘图区域高度 + """ + pass + + def _draw_labels(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制刻度标签(子类必须实现) + + Args: + painter: QPainter 对象 + x: 绘图区域左上角 X 坐标 + y: 绘图区域左上角 Y 坐标 + width: 绘图区域宽度 + height: 绘图区域高度 + """ + raise NotImplementedError("子类必须实现 _draw_labels 方法") + + def _draw_bottom_baseline(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制底部基线(辅助方法) + + Args: + painter: QPainter 对象 + x: 绘图区域左上角 X 坐标 + y: 绘图区域左上角 Y 坐标 + width: 绘图区域宽度 + height: 绘图区域高度 + """ + painter.setPen(QPen(QColor(80, 80, 80), 1)) + painter.drawLine(x, y + height, x + width, y + height) + + def _draw_max_label(self, painter: QPainter, x: int, y: int): + """绘制左侧Y轴最大值标签(辅助方法) + + Args: + painter: QPainter 对象 + x: 绘图区域左上角 X 坐标 + y: 绘图区域左上角 Y 坐标 + """ + if self._max_count > 0: + painter.setPen(QColor(120, 120, 120)) + font = QFont() + font.setPointSize(7) + painter.setFont(font) + max_text = str(self._max_count) + painter.drawText(5, y + 10, max_text) + + +class LuminanceHistogramWidget(BaseHistogram): + """明度直方图组件 - 参考Lightroom风格设计,显示图片的明度分布和Zone分区""" + zone_pressed = Signal(int) # 信号:Zone被按下 (0-7) + zone_released = Signal() # 信号:Zone被释放 + zone_changed = Signal(int) # 信号:当前Zone变化 (0-7) + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(180) + self.setMaximumHeight(220) + self.setStyleSheet("background-color: #141414; border-radius: 4px;") + + self._highlight_zones = [] # 高亮显示的区域列表 + self._pressed_zone = -1 # 当前按下的Zone + self._current_zone = -1 # 当前选中的Zone + + # 启用鼠标跟踪 + self.setMouseTracking(True) + + def set_image(self, image): + """设置图片并计算直方图""" + histogram = calculate_histogram(image) + self.set_data(histogram) + + def set_highlight_zones(self, zones): + """设置高亮显示的区域 + + Args: + zones: 区域编号列表,如 ["3-4", "5-6"] + """ + self._highlight_zones = zones + self.update() + + def set_current_zone(self, zone: int): + """设置当前选中的Zone (0-7)""" + if 0 <= zone <= 7 and zone != self._current_zone: + self._current_zone = zone + self.zone_changed.emit(zone) + self.update() + + def clear_highlight(self): + """清除高亮""" + self._highlight_zones = [] + self.update() + + def clear(self): + """清除直方图数据""" + super().clear() + self._highlight_zones = [] + self._pressed_zone = -1 + self._current_zone = -1 + self.update() + + def get_zone_from_luminance(self, luminance: int) -> int: + """根据明度值获取Zone (0-7)""" + return min(7, luminance // 32) + + def get_zone_label(self, zone: int) -> str: + """获取Zone的显示标签""" + labels = ["0", "1", "2", "3", "4", "5", "6", "7"] + return labels[zone] if 0 <= zone <= 7 else "--" + + def _draw_histogram(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制直方图曲线 - LR风格""" + if self._max_count == 0: + return + + # 绘制Zone背景色块(类似LR的风格) + self._draw_zone_background(painter, x, y, width, height) + + # 使用渐变填充,从浅灰到白色 + gradient = QLinearGradient(x, y + height, x, y) + gradient.setColorAt(0, QColor(120, 120, 120)) + gradient.setColorAt(1, QColor(200, 200, 200)) + + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(gradient) + + # 每个明度值对应的宽度 + bar_width = width / 256.0 + + # 绘制直方图柱子 + for i in range(256): + # 计算柱子高度 - 使用相对最大值的比例 + bar_height = (self._histogram[i] / self._max_count) * height + + if bar_height > 0: + # 绘制柱子 + bar_x = x + i * bar_width + bar_y = y + height - bar_height + + # 计算柱子宽度 + if i == 255: + current_bar_width = max(1, int(x + width - bar_x)) + else: + next_bar_x = x + (i + 1) * bar_width + current_bar_width = max(1, int(next_bar_x - bar_x + 0.5)) + + painter.drawRect(int(bar_x), int(bar_y), current_bar_width, int(bar_height)) + + def _draw_zone_background(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制Zone背景色块 - LR风格""" + zone_width = width / 8.0 + + # Zone颜色配置 - 使用更 subtle 的背景色 + zone_bg_colors = [ + QColor(30, 30, 30), # Zone 0: 极暗 + QColor(35, 35, 35), # Zone 1: 暗 + QColor(40, 40, 40), # Zone 2: 偏暗 + QColor(45, 45, 45), # Zone 3: 中灰 + QColor(50, 50, 50), # Zone 4: 偏亮 + QColor(55, 55, 55), # Zone 5: 亮 + QColor(60, 60, 60), # Zone 6: 很亮 + QColor(65, 65, 65), # Zone 7: 极亮 + ] + + # 按下状态或选中状态的Zone背景色(更亮一些) + zone_active_colors = [ + QColor(50, 50, 60), # Zone 0: 极暗 + QColor(55, 55, 65), # Zone 1: 暗 + QColor(60, 60, 70), # Zone 2: 偏暗 + QColor(65, 65, 75), # Zone 3: 中灰 + QColor(70, 70, 80), # Zone 4: 偏亮 + QColor(75, 75, 85), # Zone 5: 亮 + QColor(80, 80, 90), # Zone 6: 很亮 + QColor(85, 85, 95), # Zone 7: 极亮 + ] + + for i in range(8): + zone_x = x + i * zone_width + + # 如果是按下的Zone或当前选中的Zone,使用高亮背景色 + if i == self._pressed_zone or i == self._current_zone: + bg_color = zone_active_colors[i] + else: + bg_color = zone_bg_colors[i] + + # 绘制Zone背景 + painter.fillRect( + int(zone_x), y, + int(zone_width + 0.5), height, + bg_color + ) + + # 如果当前Zone被按下或选中,绘制边框 + if i == self._pressed_zone or i == self._current_zone: + painter.setPen(QPen(QColor(0, 150, 255), 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(int(zone_x), y, int(zone_width), height) + + # 绘制Zone分隔线 + pen = QPen(QColor(80, 80, 80), 1) + painter.setPen(pen) + for i in range(1, 8): + line_x = int(x + i * zone_width) + painter.drawLine(line_x, y, line_x, y + height) + + def _draw_custom_overlay(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制高亮区域 - LR风格的黄色覆盖""" + if not self._highlight_zones: + return + + zone_width = width / 8.0 + + for zone in self._highlight_zones: + min_lum, max_lum = get_zone_bounds(zone) + zone_index = min_lum // 32 + + # 计算区域位置 + start_x = int(x + zone_index * zone_width) + end_x = int(x + (zone_index + 1) * zone_width) + zone_width_px = end_x - start_x + + # 绘制黄色半透明覆盖层 + highlight_color = QColor(255, 200, 50, 60) + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(highlight_color) + painter.drawRect(start_x, y, zone_width_px, height) + + # 绘制黄色边框 + painter.setPen(QPen(QColor(255, 200, 50, 150), 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawRect(start_x, y, zone_width_px, height) + + # 绘制区域编号文字 + font = QFont() + font.setPointSize(9) + font.setBold(True) + painter.setFont(font) + + text_color = QColor(255, 220, 100) + painter.setPen(text_color) + + # 在区域中间显示编号 + text = zone + text_rect = painter.boundingRect( + start_x, y + height // 2 - 12, + zone_width_px, 24, + Qt.AlignmentFlag.AlignCenter, text + ) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + + def _draw_labels(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制刻度标签 - Zone 0-8 风格""" + font = QFont() + font.setPointSize(8) + painter.setFont(font) + + # 绘制底部刻度线和数值 - Zone 0 到 8 + zone_width = width / 8.0 + + for i in range(9): # 0, 1, 2, 3, 4, 5, 6, 7, 8 + tick_x = int(x + i * zone_width) + + # 绘制刻度线 + painter.setPen(QColor(100, 100, 100)) + painter.drawLine(tick_x, y + height, tick_x, y + height + 4) + + # 绘制刻度值 (0-8) + text = str(i) + text_rect = painter.boundingRect( + tick_x - 15, y + height + 6, + 30, 18, + Qt.AlignmentFlag.AlignCenter, text + ) + painter.setPen(QColor(150, 150, 150)) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + + # 绘制底部基线 + self._draw_bottom_baseline(painter, x, y, width, height) + + # 绘制左侧Y轴标签(最大值) + self._draw_max_label(painter, x, y) + + def _get_zone_from_pos(self, pos): + """根据鼠标位置获取对应的Zone (0-7) + + Args: + pos: 鼠标位置 (QPoint) + + Returns: + Zone编号 (0-7),如果不在有效区域返回 -1 + """ + draw_width = self.width() - self._margin_left - self._margin_right + draw_height = self.height() - self._margin_top - self._margin_bottom + + # 检查是否在绘图区域内 + if not (self._margin_left <= pos.x() <= self._margin_left + draw_width and + self._margin_top <= pos.y() <= self._margin_top + draw_height): + return -1 + + # 计算Zone (0-7) + zone_width = draw_width / 8.0 + zone_index = int((pos.x() - self._margin_left) / zone_width) + return max(0, min(7, zone_index)) + + def mousePressEvent(self, event): + """鼠标按下事件""" + if event.button() == Qt.MouseButton.LeftButton: + zone = self._get_zone_from_pos(event.pos()) + if zone >= 0: + self._pressed_zone = zone + self._current_zone = zone + self.zone_pressed.emit(zone) + self.zone_changed.emit(zone) + self.update() + event.accept() + + def mouseMoveEvent(self, event): + """鼠标移动事件""" + if self._pressed_zone >= 0: + zone = self._get_zone_from_pos(event.pos()) + if zone >= 0 and zone != self._pressed_zone: + self._pressed_zone = zone + self._current_zone = zone + self.zone_pressed.emit(zone) + self.zone_changed.emit(zone) + self.update() + event.accept() + + def mouseReleaseEvent(self, event): + """鼠标释放事件""" + if event.button() == Qt.MouseButton.LeftButton and self._pressed_zone >= 0: + self._pressed_zone = -1 + self.zone_released.emit() + self.update() + event.accept() + + +class RGBHistogramWidget(BaseHistogram): + """RGB直方图组件 - 显示图片的RGB三通道分布""" + + def __init__(self, parent=None): + super().__init__(parent) + self.setMinimumHeight(120) + self.setMaximumHeight(180) + + # RGB三通道数据 + self._histogram_r = [0] * 256 + self._histogram_g = [0] * 256 + self._histogram_b = [0] * 256 + + # 调整边距以适应标题和图例 + self._margin_top = 25 # 顶部留空间给标题 + self._margin_right = 10 + + def set_image(self, image): + """设置图片并计算RGB直方图 + + Args: + image: QImage 对象 + """ + self._histogram_r, self._histogram_g, self._histogram_b = calculate_rgb_histogram(image) + + # 计算最大值用于归一化 + max_r = max(self._histogram_r) if self._histogram_r else 0 + max_g = max(self._histogram_g) if self._histogram_g else 0 + max_b = max(self._histogram_b) if self._histogram_b else 0 + self._max_count = max(max_r, max_g, max_b) + self.update() + + def clear(self): + """清除直方图数据""" + self._histogram_r = [0] * 256 + self._histogram_g = [0] * 256 + self._histogram_b = [0] * 256 + super().clear() + + def _draw_histogram(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制RGB直方图 + + 三条曲线叠加显示:R(红色)、G(绿色)、B(蓝色) + """ + if self._max_count == 0: + return + + # 每个亮度值对应的宽度 + bar_width = width / 256.0 + + # 绘制三个通道的直方图(从后往前绘制,确保重叠区域可见) + channels = [ + (self._histogram_b, QColor(0, 100, 255, 180)), # 蓝色通道(最底层) + (self._histogram_g, QColor(0, 200, 0, 180)), # 绿色通道 + (self._histogram_r, QColor(255, 50, 50, 180)), # 红色通道(最顶层) + ] + + for histogram, color in channels: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(color) + + # 绘制直方图柱子 + for i in range(256): + # 计算柱子高度 - 使用相对最大值的比例 + bar_height = (histogram[i] / self._max_count) * height + + if bar_height > 0: + # 绘制柱子 + bar_x = x + i * bar_width + bar_y = y + height - bar_height + + # 计算柱子宽度 + if i == 255: + current_bar_width = max(1, int(x + width - bar_x)) + else: + next_bar_x = x + (i + 1) * bar_width + current_bar_width = max(1, int(next_bar_x - bar_x + 0.5)) + + painter.drawRect(int(bar_x), int(bar_y), current_bar_width, int(bar_height)) + + def _draw_custom_overlay(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制图例(R、G、B标识)""" + legend_y = y - 5 + legend_items = [ + ("R", QColor(255, 50, 50)), + ("G", QColor(0, 200, 0)), + ("B", QColor(0, 100, 255)) + ] + + legend_x = x + width - 60 + for text, color in legend_items: + painter.setPen(color) + painter.drawText(legend_x, legend_y, text) + legend_x += 20 + + def _draw_labels(self, painter: QPainter, x: int, y: int, width: int, height: int): + """绘制刻度标签""" + # 绘制标题 + self._draw_title(painter) + + # 绘制底部刻度线和数值 + font = QFont() + font.setPointSize(7) + painter.setFont(font) + + tick_positions = [0, 64, 128, 192, 255] + for value in tick_positions: + tick_x = int(x + value * width / 256.0) + + # 绘制刻度线 + painter.setPen(QColor(100, 100, 100)) + painter.drawLine(tick_x, y + height, tick_x, y + height + 3) + + # 绘制刻度值 + text = str(value) + text_rect = painter.boundingRect( + tick_x - 15, y + height + 5, + 30, 14, + Qt.AlignmentFlag.AlignCenter, text + ) + painter.setPen(QColor(150, 150, 150)) + painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) + + # 绘制底部基线 + self._draw_bottom_baseline(painter, x, y, width, height) + + # 绘制左侧Y轴标签(最大值) + self._draw_max_label(painter, x, y) + + def _draw_title(self, painter: QPainter): + """绘制标题""" + painter.setPen(QColor(200, 200, 200)) + font = painter.font() + font.setPointSize(9) + painter.setFont(font) + painter.drawText(10, 18, "RGB直方图") diff --git a/ui/interfaces.py b/ui/interfaces.py new file mode 100644 index 0000000000000000000000000000000000000000..9827eb204143317636e42b7adb9af80556c750d3 --- /dev/null +++ b/ui/interfaces.py @@ -0,0 +1,574 @@ +# 标准库导入 +# 无 + +# 第三方库导入 +from PySide6.QtCore import Qt, QTimer, Signal +from PySide6.QtGui import QColor +from PySide6.QtWidgets import ( + QFileDialog, QHBoxLayout, QLabel, QScrollArea, QSplitter, + QSizePolicy, QSpacerItem, QVBoxLayout, QWidget +) +from qfluentwidgets import ( + ComboBox, FluentIcon, InfoBar, InfoBarPosition, PrimaryPushButton, + PushSettingCard, SettingCardGroup, SpinBox, SwitchButton, isDarkTheme +) + +# 项目模块导入 +from core import get_color_info, get_config_manager +from dialogs import AboutDialog, UpdateAvailableDialog +from version import version_manager +from .canvases import ImageCanvas, LuminanceCanvas +from .cards import ColorCardPanel +from .color_wheel import HSBColorWheel +from .histograms import LuminanceHistogramWidget, RGBHistogramWidget + + +# 可选的色彩模式列表 +AVAILABLE_COLOR_MODES = ['HSB', 'LAB', 'HSL', 'CMYK', 'RGB'] + + +def get_title_color(): + """获取标题颜色""" + if isDarkTheme(): + return QColor(255, 255, 255) + else: + return QColor(40, 40, 40) + + +class ColorExtractInterface(QWidget): + """色彩提取界面""" + + def __init__(self, parent=None): + super().__init__(parent) + self._dragging_index = -1 # 当前正在拖动的采样点索引 + self.setup_ui() + self.setup_connections() + + def setup_ui(self): + """设置界面布局""" + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + # 主分割器(垂直) + main_splitter = QSplitter(Qt.Orientation.Vertical) + layout.addWidget(main_splitter, stretch=1) + + # 上半部分:水平分割器(图片 + 右侧组件) + top_splitter = QSplitter(Qt.Orientation.Horizontal) + top_splitter.setMinimumHeight(300) + + # 左侧:图片画布 + self.image_canvas = ImageCanvas() + self.image_canvas.setMinimumWidth(400) + top_splitter.addWidget(self.image_canvas) + + # 右侧:垂直分割器(HSB色环 + RGB直方图) + right_splitter = QSplitter(Qt.Orientation.Vertical) + right_splitter.setMinimumWidth(200) + right_splitter.setMaximumWidth(350) + + # HSB色环 + self.hsb_color_wheel = HSBColorWheel() + right_splitter.addWidget(self.hsb_color_wheel) + + # RGB直方图 + self.rgb_histogram_widget = RGBHistogramWidget() + right_splitter.addWidget(self.rgb_histogram_widget) + + right_splitter.setSizes([200, 150]) + top_splitter.addWidget(right_splitter) + + # 设置左右比例 + top_splitter.setSizes([600, 250]) + main_splitter.addWidget(top_splitter) + + # 下半部分:色卡面板 + self.color_card_panel = ColorCardPanel() + self.color_card_panel.setMinimumHeight(200) + main_splitter.addWidget(self.color_card_panel) + + main_splitter.setSizes([450, 220]) + + def setup_connections(self): + """设置信号连接""" + self.image_canvas.color_picked.connect(self.on_color_picked) + self.image_canvas.image_loaded.connect(self.on_image_loaded) + self.image_canvas.image_data_loaded.connect(self.on_image_data_loaded) + self.image_canvas.open_image_requested.connect(self.open_image) + self.image_canvas.change_image_requested.connect(self.open_image) + self.image_canvas.clear_image_requested.connect(self.clear_image) + self.image_canvas.image_cleared.connect(self.on_image_cleared) + + def open_image(self): + """打开图片文件""" + file_path, _ = QFileDialog.getOpenFileName( + self, + "选择图片", + "", + "图片文件 (*.png *.jpg *.jpeg *.bmp *.gif)" + ) + + if file_path: + self.image_canvas.set_image(file_path) + + def on_image_loaded(self, file_path): + """图片加载完成回调""" + pass + + def on_image_data_loaded(self, pixmap, image): + """图片数据加载完成回调(用于同步到其他面板)""" + window = self.window() + if window and hasattr(window, 'sync_image_data_to_luminance'): + # 立即同步图片数据到明度面板(只设置图片,不计算) + # 明度面板会自己延迟执行耗时操作 + window.sync_image_data_to_luminance(pixmap, image) + + # 更新RGB直方图 + self.rgb_histogram_widget.set_image(image) + + def on_color_picked(self, index, rgb): + """颜色提取回调""" + color_info = get_color_info(*rgb) + self.color_card_panel.update_color(index, color_info) + # 更新HSB色环上的采样点 + self.hsb_color_wheel.update_sample_point(index, rgb) + + def clear_image(self): + """清空图片""" + self.image_canvas.clear_image() + self.color_card_panel.clear_all() + # 清除HSB色环和RGB直方图 + self.hsb_color_wheel.clear_sample_points() + self.rgb_histogram_widget.clear() + + def on_image_cleared(self): + """图片已清空回调(同步清除明度面板)""" + # 同步清除明度提取面板 + window = self.window() + if window and hasattr(window, 'sync_clear_to_luminance'): + window.sync_clear_to_luminance() + + +class LuminanceExtractInterface(QWidget): + """明度提取界面""" + + def __init__(self, parent=None): + super().__init__(parent) + self._dragging_index = -1 # 当前正在拖动的采样点索引 + self.setup_ui() + self.setup_connections() + + def setup_ui(self): + """设置界面布局""" + layout = QVBoxLayout(self) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(10) + + splitter = QSplitter(Qt.Orientation.Vertical) + layout.addWidget(splitter, stretch=1) + + self.luminance_canvas = LuminanceCanvas() + splitter.addWidget(self.luminance_canvas) + + self.histogram_widget = LuminanceHistogramWidget() + splitter.addWidget(self.histogram_widget) + + splitter.setSizes([400, 150]) + + def setup_connections(self): + """设置信号连接""" + self.luminance_canvas.luminance_picked.connect(self.on_luminance_picked) + self.luminance_canvas.image_loaded.connect(self.on_image_loaded) + self.luminance_canvas.open_image_requested.connect(self.open_image) + self.luminance_canvas.change_image_requested.connect(self.change_image) + self.luminance_canvas.clear_image_requested.connect(self.clear_image) + self.luminance_canvas.image_cleared.connect(self.on_image_cleared) + self.luminance_canvas.picker_dragging.connect(self.on_picker_dragging) + + # 连接直方图点击信号 + self.histogram_widget.zone_pressed.connect(self.on_histogram_zone_pressed) + self.histogram_widget.zone_released.connect(self.on_histogram_zone_released) + + def open_image(self): + """打开图片文件(由主窗口处理)""" + # 实际打开操作由主窗口处理,然后同步到本界面 + window = self.window() + if window and hasattr(window, 'open_image_for_luminance'): + window.open_image_for_luminance() + + def change_image(self): + """更换图片(由主窗口处理)""" + window = self.window() + if window and hasattr(window, 'open_image_for_luminance'): + window.open_image_for_luminance() + + def set_image(self, image_path): + """设置图片(由主窗口调用同步)""" + self.luminance_canvas.set_image(image_path) + self.histogram_widget.set_image(self.luminance_canvas.get_image()) + # 导入图片时不显示高亮 + self.histogram_widget.clear_highlight() + + def set_image_data(self, pixmap, image): + """设置图片数据(直接使用已加载的图片,避免重复加载)""" + self.luminance_canvas.set_image_data(pixmap, image) + # 延迟更新直方图,避免与区域提取同时执行 + QTimer.singleShot(400, lambda: self._update_histogram_with_image(image)) + + def _update_histogram_with_image(self, image): + """更新直方图(延迟执行)""" + self.histogram_widget.set_image(image) + # 导入图片时不显示高亮 + self.histogram_widget.clear_highlight() + + def on_image_loaded(self, file_path): + """图片加载完成回调""" + # 更新直方图 + self.histogram_widget.set_image(self.luminance_canvas.get_image()) + # 导入图片时不显示高亮 + self.histogram_widget.clear_highlight() + + def on_luminance_picked(self, index, zone): + """明度提取回调 - 拖动时实时更新黄框""" + # 只在拖动过程中更新高亮 + if self._dragging_index == index: + self.histogram_widget.set_highlight_zones([zone]) + + def on_picker_dragging(self, index, is_dragging): + """取色点拖动状态回调 + + Args: + index: 取色点索引 + is_dragging: 是否正在拖动 + """ + if is_dragging: + # 记录正在拖动的采样点索引 + self._dragging_index = index + # 显示当前拖动采样点的区域高亮 + zones = self.luminance_canvas.get_picker_zones() + if 0 <= index < len(zones): + self.histogram_widget.set_highlight_zones([zones[index]]) + else: + # 拖动结束,清除记录和高亮 + self._dragging_index = -1 + self.histogram_widget.clear_highlight() + + def update_histogram_highlight(self): + """更新直方图高亮区域(仅在拖动时使用)""" + zones = self.luminance_canvas.get_picker_zones() + # 去重 + unique_zones = list(set(zones)) + self.histogram_widget.set_highlight_zones(unique_zones) + + def clear_image(self): + """清空图片""" + self.luminance_canvas.clear_image() + self.histogram_widget.clear() + + def on_image_cleared(self): + """图片已清空回调(同步清除色彩面板)""" + # 同步清除色彩提取面板 + window = self.window() + if window and hasattr(window, 'sync_clear_to_color'): + window.sync_clear_to_color() + + def on_histogram_zone_pressed(self, zone): + """直方图Zone被按下时调用 + + Args: + zone: Zone编号 (0-7) + """ + # 在画布上高亮显示该Zone的亮度范围 + self.luminance_canvas.highlight_zone(zone) + + def on_histogram_zone_released(self): + """直方图Zone被释放时调用""" + # 清除画布上的高亮显示 + self.luminance_canvas.clear_zone_highlight() + + +class SettingsInterface(QWidget): + """设置界面""" + + # 信号:16进制显示开关状态改变 + hex_display_changed = Signal(bool) + # 信号:色彩模式改变 + color_modes_changed = Signal(list) + # 信号:色彩提取采样点数改变 + color_sample_count_changed = Signal(int) + # 信号:明度提取采样点数改变 + luminance_sample_count_changed = Signal(int) + + def __init__(self, parent=None): + super().__init__(parent) + self.setObjectName('settings') + self._config_manager = get_config_manager() + self._hex_visible = self._config_manager.get('settings.hex_visible', True) + self._color_modes = self._config_manager.get('settings.color_modes', ['HSB', 'LAB']) + self._color_sample_count = self._config_manager.get('settings.color_sample_count', 5) + self._luminance_sample_count = self._config_manager.get('settings.luminance_sample_count', 5) + self.setup_ui() + + def setup_ui(self): + """设置界面布局""" + # 创建滚动区域 + self.scroll_area = QScrollArea(self) + self.scroll_area.setWidgetResizable(True) + self.scroll_area.setStyleSheet("QScrollArea { border: none; }") + + # 创建内容容器 + self.content_widget = QWidget() + self.content_widget.setStyleSheet("background: transparent;") + layout = QVBoxLayout(self.content_widget) + layout.setContentsMargins(36, 36, 36, 36) + layout.setSpacing(20) + layout.setAlignment(Qt.AlignmentFlag.AlignTop) + + # 标题 + title_label = QLabel("设置") + title_color = get_title_color() + title_label.setStyleSheet(f"font-size: 28px; font-weight: bold; color: {title_color.name()};") + layout.addWidget(title_label) + + # 显示设置分组 + self.display_group = SettingCardGroup("显示设置", self.content_widget) + + # 16进制颜色值显示开关卡片 + self.hex_display_card = self._create_switch_card( + FluentIcon.PALETTE, + "显示16进制颜色值", + "在色彩提取面板的色卡中显示16进制颜色值和复制按钮", + self._hex_visible + ) + self.display_group.addSettingCard(self.hex_display_card) + + # 色彩模式选择卡片 + self.color_mode_card = self._create_color_mode_card() + self.display_group.addSettingCard(self.color_mode_card) + + # 色彩提取采样点数卡片 + self.color_sample_count_card = self._create_spin_box_card( + FluentIcon.PALETTE, + "色彩提取采样点数", + "设置色彩提取面板的采样点数量(2-5)", + self._color_sample_count, + 2, + 5, + self._on_color_sample_count_changed + ) + self.display_group.addSettingCard(self.color_sample_count_card) + + # 明度提取采样点数卡片 + self.luminance_sample_count_card = self._create_spin_box_card( + FluentIcon.BRIGHTNESS, + "明度提取采样点数", + "设置明度提取面板的采样点数量(2-5)", + self._luminance_sample_count, + 2, + 5, + self._on_luminance_sample_count_changed + ) + self.display_group.addSettingCard(self.luminance_sample_count_card) + + layout.addWidget(self.display_group) + + # 帮助分组 + self.help_group = SettingCardGroup("帮助", self.content_widget) + + # 版本更新卡片 + self.update_card = PushSettingCard( + "检查更新", + FluentIcon.DOWNLOAD, + "版本更新", + "检查软件是否有新版本可用", + self.help_group + ) + self.update_card.clicked.connect(self.on_check_update) + # 设置按钮固定宽度 + self.update_card.button.setFixedWidth(130) + self.help_group.addSettingCard(self.update_card) + + # 关于卡片 + self.about_card = PushSettingCard( + "查看", + FluentIcon.INFO, + "关于 Color Card", + "查看项目、文档等信息", + self.help_group + ) + self.about_card.clicked.connect(self.on_show_about) + # 设置按钮固定宽度,与检查更新按钮一致 + self.about_card.button.setFixedWidth(130) + self.help_group.addSettingCard(self.about_card) + + layout.addWidget(self.help_group) + + # 添加弹性空间 + layout.addStretch() + + # 将内容容器设置到滚动区域 + self.scroll_area.setWidget(self.content_widget) + + # 设置主布局 + main_layout = QVBoxLayout(self) + main_layout.setContentsMargins(0, 0, 0, 0) + main_layout.addWidget(self.scroll_area) + + def _create_switch_card(self, icon, title, content, initial_checked): + """创建自定义开关卡片""" + card = PushSettingCard("", icon, title, content, self.display_group) + card.button.setVisible(False) # 隐藏默认按钮 + + # 创建开关按钮 + switch = SwitchButton(self.content_widget) + switch.setChecked(initial_checked) + switch.checkedChanged.connect(self._on_hex_display_changed) + + # 将开关添加到卡片布局 + card.hBoxLayout.addWidget(switch, 0, Qt.AlignmentFlag.AlignRight) + card.hBoxLayout.addSpacing(16) + + # 保存开关引用 + card.switch_button = switch + + return card + + def _create_spin_box_card(self, icon, title, content, initial_value, min_value, max_value, callback): + """创建自定义下拉列表卡片""" + card = PushSettingCard("", icon, title, content, self.display_group) + card.button.setVisible(False) + + # 创建ComboBox控件 + combo_box = ComboBox(self.content_widget) + # 添加数值选项 + for i in range(min_value, max_value + 1): + combo_box.addItem(str(i)) + combo_box.setCurrentText(str(initial_value)) + combo_box.setFixedWidth(80) + combo_box.currentTextChanged.connect(lambda text: callback(int(text))) + + # 将ComboBox添加到卡片布局 + card.hBoxLayout.addWidget(combo_box, 0, Qt.AlignmentFlag.AlignRight) + card.hBoxLayout.addSpacing(16) + + # 保存ComboBox引用 + card.combo_box = combo_box + + return card + + def _create_color_mode_card(self): + """创建色彩模式选择卡片""" + card = PushSettingCard( + "", + FluentIcon.BRUSH, + "色彩模式显示", + "选择在色卡中显示的两种色彩模式", + self.display_group + ) + card.button.setVisible(False) # 隐藏默认按钮 + + # 创建选择控件容器 + combo_container = QWidget(self.content_widget) + combo_layout = QHBoxLayout(combo_container) + combo_layout.setContentsMargins(0, 0, 0, 0) + combo_layout.setSpacing(10) + + # 第一列选择 + self.mode_combo_1 = ComboBox(combo_container) + self.mode_combo_1.addItems(AVAILABLE_COLOR_MODES) + self.mode_combo_1.setCurrentText(self._color_modes[0]) + self.mode_combo_1.setFixedWidth(80) + self.mode_combo_1.currentTextChanged.connect(self._on_color_mode_changed) + + # 分隔标签 + separator = QLabel("+", combo_container) + separator.setStyleSheet("color: gray;") + + # 第二列选择 + self.mode_combo_2 = ComboBox(combo_container) + self.mode_combo_2.addItems(AVAILABLE_COLOR_MODES) + self.mode_combo_2.setCurrentText(self._color_modes[1]) + self.mode_combo_2.setFixedWidth(80) + self.mode_combo_2.currentTextChanged.connect(self._on_color_mode_changed) + + combo_layout.addWidget(self.mode_combo_1) + combo_layout.addWidget(separator) + combo_layout.addWidget(self.mode_combo_2) + + # 将选择控件添加到卡片布局 + card.hBoxLayout.addWidget(combo_container, 0, Qt.AlignmentFlag.AlignRight) + card.hBoxLayout.addSpacing(16) + + return card + + def _on_hex_display_changed(self, checked): + """16进制显示开关状态改变""" + self._hex_visible = checked + self._config_manager.set('settings.hex_visible', checked) + self._config_manager.save() + self.hex_display_changed.emit(checked) + + def _on_color_mode_changed(self): + """色彩模式选择改变""" + mode1 = self.mode_combo_1.currentText() + mode2 = self.mode_combo_2.currentText() + + # 如果两列选择相同,自动调整第二列 + if mode1 == mode2: + # 找到下一个不同的模式 + for mode in AVAILABLE_COLOR_MODES: + if mode != mode1: + self.mode_combo_2.setCurrentText(mode) + mode2 = mode + break + + self._color_modes = [mode1, mode2] + self._config_manager.set('settings.color_modes', self._color_modes) + self._config_manager.save() + self.color_modes_changed.emit(self._color_modes) + + def _on_color_sample_count_changed(self, value): + """色彩提取采样点数改变""" + self._color_sample_count = value + self._config_manager.set('settings.color_sample_count', value) + self._config_manager.save() + self.color_sample_count_changed.emit(value) + + def _on_luminance_sample_count_changed(self, value): + """明度提取采样点数改变""" + self._luminance_sample_count = value + self._config_manager.set('settings.luminance_sample_count', value) + self._config_manager.save() + self.luminance_sample_count_changed.emit(value) + + def set_hex_visible(self, visible): + """设置16进制显示开关状态""" + self._hex_visible = visible + if hasattr(self.hex_display_card, 'switch_button'): + self.hex_display_card.switch_button.setChecked(visible) + + def is_hex_visible(self): + """获取16进制显示开关状态""" + return self._hex_visible + + def set_color_modes(self, modes): + """设置色彩模式选择""" + if len(modes) >= 2: + self._color_modes = [modes[0], modes[1]] + self.mode_combo_1.setCurrentText(modes[0]) + self.mode_combo_2.setCurrentText(modes[1]) + + def get_color_modes(self): + """获取当前色彩模式""" + return self._color_modes + + def on_check_update(self): + """检查更新按钮点击""" + current_version = version_manager.get_version() + UpdateAvailableDialog.check_update(self, current_version) + + def on_show_about(self): + """显示关于对话框""" + dialog = AboutDialog(self) + dialog.exec() diff --git a/ui/main_window.py b/ui/main_window.py new file mode 100644 index 0000000000000000000000000000000000000000..791ac0ef540d9abd37406cfef8f8ee2ee61237a0 --- /dev/null +++ b/ui/main_window.py @@ -0,0 +1,267 @@ +# 第三方库导入 +from PySide6.QtCore import Qt, QTimer +from PySide6.QtGui import QIcon +from PySide6.QtWidgets import ( + QFileDialog, QHBoxLayout, QLabel, QSplitter, QVBoxLayout, QWidget +) +from qfluentwidgets import FluentIcon, FluentWindow, NavigationItemPosition, qrouter + +# 项目模块导入 +from core import get_color_info +from core import get_config_manager +from version import version_manager +from .interfaces import ColorExtractInterface, LuminanceExtractInterface, SettingsInterface +from .cards import ColorCardPanel +from .histograms import LuminanceHistogramWidget, RGBHistogramWidget +from .color_wheel import HSBColorWheel +from .canvases import ImageCanvas, LuminanceCanvas + + +class MainWindow(FluentWindow): + """主窗口""" + + def __init__(self): + super().__init__() + self._version = version_manager.get_version() + self.setWindowTitle(f"取色卡 · Color Card · {self._version}") + self.setMinimumSize(800, 550) + + # 加载配置 + self._config_manager = get_config_manager() + self._config = self._config_manager.load() + + # 防止清空同步的递归标志 + self._is_clearing = False + + # 应用窗口大小配置 + window_config = self._config.get('window', {}) + width = window_config.get('width', 940) + height = window_config.get('height', 660) + is_maximized = window_config.get('is_maximized', False) + self.resize(width, height) + + self.create_sub_interface() + self.setup_navigation() + + # 如果之前是最大化状态,恢复最大化 + if is_maximized: + self.showMaximized() + + def closeEvent(self, event): + """窗口关闭事件,保存配置""" + # 保存窗口最大化状态 + is_maximized = self.isMaximized() + self._config_manager.set('window.is_maximized', is_maximized) + + # 保存窗口大小(如果最大化,保存正常尺寸而非最大化尺寸) + if is_maximized: + normal_geometry = self.normalGeometry() + self._config_manager.set('window.width', normal_geometry.width()) + self._config_manager.set('window.height', normal_geometry.height()) + else: + self._config_manager.set('window.width', self.width()) + self._config_manager.set('window.height', self.height()) + + self._config_manager.save() + event.accept() + + def create_sub_interface(self): + """创建子界面""" + # 色彩提取界面 + self.color_extract_interface = ColorExtractInterface(self) + self.color_extract_interface.setObjectName('colorExtract') + qrouter.setDefaultRouteKey(self.stackedWidget, 'colorExtract') + self.stackedWidget.addWidget(self.color_extract_interface) + + # 明度提取界面 + self.luminance_extract_interface = LuminanceExtractInterface(self) + self.luminance_extract_interface.setObjectName('luminanceExtract') + self.stackedWidget.addWidget(self.luminance_extract_interface) + + # 设置界面 + self.settings_interface = SettingsInterface(self) + self.settings_interface.setObjectName('settings') + self.stackedWidget.addWidget(self.settings_interface) + + # 连接设置信号 + self._setup_settings_connections() + + def setup_navigation(self): + """设置导航栏""" + # 隐藏返回按钮 + self.navigationInterface.setReturnButtonVisible(False) + + # 设置导航栏始终展开(禁用折叠) + # 注意:必须先设置展开宽度,再设置不可折叠,否则展开时会使用默认宽度322 + self.navigationInterface.setExpandWidth(200) + self.navigationInterface.setCollapsible(False) + + # 添加 Logo 到左上角 + self._setup_logo() + + # 色彩提取 + self.addSubInterface( + self.color_extract_interface, + FluentIcon.PALETTE, + "色彩提取", + position=NavigationItemPosition.TOP + ) + + # 明度提取 + self.addSubInterface( + self.luminance_extract_interface, + FluentIcon.BRIGHTNESS, + "明度提取", + position=NavigationItemPosition.TOP + ) + + # 设置(放在底部) + self.addSubInterface( + self.settings_interface, + FluentIcon.SETTING, + "设置", + position=NavigationItemPosition.BOTTOM + ) + + # 设置默认选中的导航项 + self.navigationInterface.setCurrentItem(self.color_extract_interface.objectName()) + + def _setup_logo(self): + """在导航栏左上角设置 Logo""" + logo_label = QLabel(self.navigationInterface.panel) + logo_label.setObjectName('logoLabel') + + # 加载 Logo 图标 + logo_path = 'd:\\青山公仔\\应用\\Py测试\\color_card\\logo\\Color Card_logo.ico' + + # 使用 QIcon 加载 ICO 文件以获取最佳分辨率 + from PySide6.QtCore import QSize + icon = QIcon(logo_path) + + # 获取设备像素比(支持高 DPI 屏幕) + pixel_ratio = self.devicePixelRatio() + + # 获取所需尺寸(请求更大的图标以获得更好的质量) + # 在高 DPI 屏幕上请求更高分辨率的图标 + icon_size = int(64 * pixel_ratio) + pixmap = icon.pixmap(icon.actualSize(QSize(icon_size, icon_size))) + + if not pixmap.isNull(): + # 将图标缩放到目标显示尺寸(使用高质量缩放) + target_size = int(28 * pixel_ratio) + scaled_pixmap = pixmap.scaled( + target_size, target_size, + Qt.AspectRatioMode.KeepAspectRatio, + Qt.TransformationMode.SmoothTransformation + ) + # 设置设备像素比,确保在高 DPI 屏幕上正确显示 + scaled_pixmap.setDevicePixelRatio(pixel_ratio) + + logo_label.setPixmap(scaled_pixmap) + logo_label.setFixedSize(40, 40) + logo_label.setAlignment(Qt.AlignmentFlag.AlignCenter) + + # 将 Logo 插入到导航栏顶部布局的开头(在返回按钮之前) + top_layout = self.navigationInterface.panel.topLayout + top_layout.insertWidget(0, logo_label, 0, Qt.AlignTop) + + def open_image(self): + """打开图片(从色彩提取界面调用)""" + self.color_extract_interface.open_image() + + def open_image_for_luminance(self): + """为明度提取打开图片(实际同步到色彩提取)""" + # 调用色彩提取的打开图片功能,然后同步到明度提取 + self.color_extract_interface.open_image() + + def sync_image_to_luminance(self, image_path): + """同步图片路径到明度提取面板(保留用于兼容)""" + if image_path: + self.luminance_extract_interface.set_image(image_path) + + def sync_image_data_to_luminance(self, pixmap, image): + """同步图片数据到明度提取面板(避免重复加载)""" + self.luminance_extract_interface.set_image_data(pixmap, image) + + def sync_clear_to_luminance(self): + """同步清除明度提取面板""" + if self._is_clearing: + return + self._is_clearing = True + try: + self.luminance_extract_interface.luminance_canvas.clear_image() + self.luminance_extract_interface.histogram_widget.clear() + self._reset_window_title() + finally: + self._is_clearing = False + + def sync_clear_to_color(self): + """同步清除色彩提取面板""" + if self._is_clearing: + return + self._is_clearing = True + try: + self.color_extract_interface.image_canvas.clear_image() + self.color_extract_interface.color_card_panel.clear_all() + self._reset_window_title() + finally: + self._is_clearing = False + + def _reset_window_title(self): + """重置窗口标题""" + self.setWindowTitle(f"取色卡 · Color Card · {self._version}") + + def _setup_settings_connections(self): + """连接设置界面的信号""" + # 连接16进制显示开关信号到色卡面板 + self.settings_interface.hex_display_changed.connect( + self.color_extract_interface.color_card_panel.set_hex_visible + ) + + # 连接色彩模式改变信号到色卡面板 + self.settings_interface.color_modes_changed.connect( + self.color_extract_interface.color_card_panel.set_color_modes + ) + + # 连接色彩提取采样点数改变信号 + self.settings_interface.color_sample_count_changed.connect( + self._on_color_sample_count_changed + ) + + # 连接明度提取采样点数改变信号 + self.settings_interface.luminance_sample_count_changed.connect( + self._on_luminance_sample_count_changed + ) + + # 应用加载的配置到色卡面板 + hex_visible = self._config_manager.get('settings.hex_visible', True) + self.color_extract_interface.color_card_panel.set_hex_visible(hex_visible) + + # 应用加载的色彩模式配置到色卡面板 + color_modes = self._config_manager.get('settings.color_modes', ['HSB', 'LAB']) + self.color_extract_interface.color_card_panel.set_color_modes(color_modes) + + # 应用加载的采样点数量配置 + color_sample_count = self._config_manager.get('settings.color_sample_count', 5) + self.color_extract_interface.image_canvas.set_picker_count(color_sample_count) + self.color_extract_interface.color_card_panel.set_card_count(color_sample_count) + + luminance_sample_count = self._config_manager.get('settings.luminance_sample_count', 5) + self.luminance_extract_interface.luminance_canvas.set_picker_count(luminance_sample_count) + self.luminance_extract_interface.histogram_widget.clear() + + def _on_color_sample_count_changed(self, count): + """色彩提取采样点数改变""" + self.color_extract_interface.image_canvas.set_picker_count(count) + self.color_extract_interface.color_card_panel.set_card_count(count) + # 更新HSB色环的采样点数量 + self.color_extract_interface.hsb_color_wheel.set_sample_count(count) + + def _on_luminance_sample_count_changed(self, count): + """明度提取采样点数改变""" + self.luminance_extract_interface.luminance_canvas.set_picker_count(count) + self.luminance_extract_interface.histogram_widget.clear() + # 如果有图片,重新计算直方图 + image = self.luminance_extract_interface.luminance_canvas.get_image() + if image and not image.isNull(): + self.luminance_extract_interface.histogram_widget.set_image(image) diff --git a/widgets/zoom_viewer.py b/ui/zoom_viewer.py similarity index 87% rename from widgets/zoom_viewer.py rename to ui/zoom_viewer.py index bd2ad55f25df9abf4d0279c75bc4d59c78e98e61..c43c778ed1d7bff56bb40ad400886c32ab3fa36c 100644 --- a/widgets/zoom_viewer.py +++ b/ui/zoom_viewer.py @@ -1,6 +1,16 @@ +# 第三方库导入 +from PySide6.QtCore import QPoint, Qt +from PySide6.QtGui import QColor, QImage, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QWidget -from PySide6.QtCore import Qt, QPoint -from PySide6.QtGui import QPainter, QImage, QColor, QPen, QBrush, QPainterPath +from qfluentwidgets import isDarkTheme + + +def get_crosshair_color(): + """获取十字准星颜色""" + if isDarkTheme(): + return QColor(200, 200, 200) + else: + return QColor(40, 40, 40) class ZoomViewer(QWidget): @@ -69,13 +79,13 @@ class ZoomViewer(QWidget): source_rect = self._image.copy(src_x, src_y, self._source_rect_size, self._source_rect_size) painter.drawImage(self.rect(), source_rect) - # 绘制中心十字准星(深色) + # 绘制中心十字准星(主题适配颜色) painter.setClipping(False) center_x = self.width() // 2 center_y = self.height() // 2 cross_size = 8 - pen = QPen(QColor(40, 40, 40), 2) + pen = QPen(get_crosshair_color(), 2) painter.setPen(pen) # 水平线 diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..664f7b3f6d0f7dd34a5c00b03293adfe0439ebde --- /dev/null +++ b/utils/__init__.py @@ -0,0 +1,14 @@ +"""工具函数模块""" + +from .icon import load_icon_universal, get_icon_path, create_fallback_icon +from .platform import set_app_user_model_id, fix_windows_taskbar_icon_for_window + +__all__ = [ + # 图标工具 + 'load_icon_universal', + 'get_icon_path', + 'create_fallback_icon', + # 平台工具 + 'set_app_user_model_id', + 'fix_windows_taskbar_icon_for_window', +] diff --git a/utils/icon.py b/utils/icon.py new file mode 100644 index 0000000000000000000000000000000000000000..714a918bc96613ae0f8cfe39318d9df925c8e353 --- /dev/null +++ b/utils/icon.py @@ -0,0 +1,86 @@ +# 标准库导入 +import os +import sys +from typing import Optional + +# 第三方库导入 +from PySide6.QtCore import Qt +from PySide6.QtGui import QColor, QIcon, QPainter, QPixmap + + +def get_base_path() -> str: + """获取应用程序基础路径 + + 支持开发环境和 PyInstaller 打包后的环境 + + Returns: + str: 应用程序基础路径 + """ + if getattr(sys, 'frozen', False): + # PyInstaller 打包后的环境 + if hasattr(sys, '_MEIPASS'): + return sys._MEIPASS + return os.path.dirname(sys.executable) + # 开发环境 - 返回项目根目录(utils/ 的父目录) + return os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + + +def get_icon_path() -> Optional[str]: + """获取图标文件路径 + + Returns: + str: 图标文件的完整路径,如果找不到则返回 None + """ + base_path = get_base_path() + + # 可能的图标路径列表 + possible_paths = [ + os.path.join(base_path, 'logo', 'Color Card_logo.ico'), + os.path.join(base_path, 'Color Card_logo.ico'), + os.path.join(base_path, 'logo.ico'), + os.path.join(base_path, 'logo', 'logo.ico'), + ] + + for path in possible_paths: + if os.path.exists(path): + return path + + return None + + +def load_icon_universal() -> QIcon: + """统一的图标加载函数,适用于所有环境 + + Returns: + QIcon: 应用程序图标对象 + """ + icon_path = get_icon_path() + + if icon_path: + icon = QIcon(icon_path) + if not icon.isNull(): + return icon + + # 如果找不到图标,创建后备图标 + return create_fallback_icon() + + +def create_fallback_icon() -> QIcon: + """创建后备图标(当找不到图标文件时使用) + + Returns: + QIcon: 后备图标对象 + """ + try: + # 创建一个简单的蓝色图标 + pixmap = QPixmap(32, 32) + pixmap.fill(QColor("#0078d4")) + + painter = QPainter(pixmap) + painter.setPen(QColor('white')) + painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "CC") + painter.end() + + return QIcon(pixmap) + except (RuntimeError, ValueError): + return QIcon() diff --git a/utils/platform.py b/utils/platform.py new file mode 100644 index 0000000000000000000000000000000000000000..8ff36945a857adf328207cfc459326891470f7e4 --- /dev/null +++ b/utils/platform.py @@ -0,0 +1,172 @@ +# 标准库导入 +import ctypes +import os +from typing import Dict, Optional + +# 第三方库导入 +from PySide6.QtCore import QObject, Qt, QTimer, Signal + +# 项目模块导入 +from .icon import get_icon_path + + +def set_app_user_model_id() -> bool: + """设置 AppUserModelID - 必须在创建 QApplication 之前调用 + + Windows 使用 AppUserModelID 来识别和分组任务栏上的应用程序。 + 如果不设置,Windows 会将 Python 解释器作为默认分组,导致图标显示异常。 + + Returns: + bool: 设置成功返回 True,失败返回 False + """ + if os.name != 'nt': # 仅 Windows 需要 + return False + + try: + # 格式:CompanyName.AppName.Version + app_id = 'HXiaoStudio.ColorCard.1.0.0' + ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(app_id) + return True + except (AttributeError, OSError): + return False + + +# 全局变量:跟踪每个窗口的图标修复状态 +_TASKBAR_ICON_FIXED_WINDOWS: Dict[int, bool] = {} + + +def fix_windows_taskbar_icon_for_window(window) -> bool: + """为特定窗口修复 Windows 任务栏图标 + + Args: + window: PySide6 窗口对象 (QMainWindow 或 QDialog) + + Returns: + bool: 修复成功返回 True,失败返回 False + """ + if os.name != 'nt': + return False + + # 使用窗口对象的 id 作为键,为每个窗口单独跟踪修复状态 + window_id = id(window) + global _TASKBAR_ICON_FIXED_WINDOWS + + # 检查此窗口是否已经修复过 + if window_id in _TASKBAR_ICON_FIXED_WINDOWS and _TASKBAR_ICON_FIXED_WINDOWS[window_id]: + return False + + try: + # 确保窗口已经显示 + if not window.isVisible(): + window.show() + window.raise_() + window.activateWindow() + + # 使用 Qt 方法获取窗口句柄 + hwnd = int(window.winId()) + + # 获取图标路径 + icon_path = get_icon_path() + + if not icon_path: + return False + + # 使用 ctypes 设置图标 + user32 = ctypes.windll.user32 + + # 加载图标 + if icon_path.lower().endswith('.ico'): + h_icon = user32.LoadImageW( + None, icon_path, + 1, # IMAGE_ICON + 0, 0, # 使用实际大小 + 0x00000010 # LR_LOADFROMFILE + ) + else: + # 对于 PNG 等格式,需要先加载为位图 + from PySide6.QtGui import QPixmap + pixmap = QPixmap(icon_path) + if not pixmap.isNull(): + h_icon = pixmap.toImage().bits() + else: + return False + + if h_icon: + # 设置图标(大图标和小图标) + user32.SendMessageW(hwnd, 0x0080, 1, h_icon) # WM_SETICON, ICON_BIG + user32.SendMessageW(hwnd, 0x0080, 0, h_icon) # WM_SETICON, ICON_SMALL + + # 强制刷新任务栏 + user32.UpdateWindow(hwnd) + + # 标记此窗口已修复 + _TASKBAR_ICON_FIXED_WINDOWS[window_id] = True + return True + + return False + + except (AttributeError, OSError, RuntimeError): + return False + + +class WindowIconMixin(QObject): + """窗口图标修复混入类,提供统一的任务栏图标修复功能 + + 使用示例: + class MyWindow(QMainWindow, WindowIconMixin): + def __init__(self): + super().__init__() + # ... 其他初始化代码 ... + + def showEvent(self, event): + super().showEvent(event) + self.setup_icon_fixing() + """ + + icon_fixed = Signal(bool) + + def __init__(self, *args, **kwargs) -> None: + super().__init__(*args, **kwargs) + self._icon_fixed: bool = False + self._fix_timer: Optional[QTimer] = None + + def setup_icon_fixing(self, delay_ms: int = 100) -> None: + """设置图标修复,在窗口显示后调用 + + Args: + delay_ms: 延迟时间(毫秒),默认 100ms + """ + if self._icon_fixed: + return + + if os.name == 'nt': + self._fix_timer = QTimer() + self._fix_timer.setSingleShot(True) + self._fix_timer.timeout.connect(self._fix_icon_safe) + self._fix_timer.start(delay_ms) + + def _fix_icon_safe(self) -> bool: + """安全修复任务栏图标 + + Returns: + bool: 修复成功返回 True,失败返回 False + """ + try: + if self._icon_fixed: + return True + + success = fix_windows_taskbar_icon_for_window(self) + self._icon_fixed = True + self.icon_fixed.emit(success) + return success + except (AttributeError, OSError, RuntimeError): + self.icon_fixed.emit(False) + return False + + def fix_taskbar_icon(self) -> bool: + """修复任务栏图标 - 兼容旧接口 + + Returns: + bool: 修复成功返回 True,失败返回 False + """ + return self._fix_icon_safe() diff --git a/version.py b/version.py new file mode 100644 index 0000000000000000000000000000000000000000..518ecee5673b506858e2cf404b519396f4f5398d --- /dev/null +++ b/version.py @@ -0,0 +1,88 @@ +from typing import Dict + + +class VersionManager: + """应用程序版本管理器,负责管理应用程序的版本信息和应用元数据""" + + def __init__(self) -> None: + """初始化版本管理器""" + # 版本号组件 + self.major: int = 1 + self.minor: int = 0 + self.patch: int = 0 + self.build: int = 0 + + # 核心版本信息 + self.version: str = f"{self.major}.{self.minor}.{self.patch}" + + # 详细版本信息结构 + self.version_info: Dict[str, int | str] = { + "major": self.major, + "minor": self.minor, + "patch": self.patch, + "build": self.build, + "full": f"{self.major}.{self.minor}.{self.patch}", + "short": f"{self.major}.{self.minor}.{self.patch}" + } + + # 应用程序元数据 + self.app_info: Dict[str, str] = { + "name": "取色卡", + "name_en": "Color Card", + "company": "浮晓 HXiao Studio", + "copyright": "© 2026 浮晓 HXiao Studio", + "description": "取色卡 - Color Card", + "internal_name": "Color_Card", + "original_filename": "Color_Card.exe", + "developer": "青山公仔", + "email": "hxiao_studio@163.com" + } + + def get_version(self) -> str: + """获取当前版本号 + + Returns: + str: 应用程序版本号,格式为"X.Y.Z" + """ + return self.version + + def get_version_info(self) -> Dict[str, int | str]: + """获取版本详细信息 + + Returns: + dict: 包含主要版本号、次要版本号、补丁版本号、构建号和完整版本号的字典 + """ + return self.version_info.copy() + + def get_app_info(self) -> Dict[str, str]: + """获取应用程序信息 + + Returns: + dict: 包含应用程序名称、公司、版权等元数据的字典 + """ + return self.app_info.copy() + + def get_full_app_name(self) -> str: + """获取完整应用程序名称(包含版本号) + + Returns: + str: 完整的应用程序名称,格式为"应用名称 v版本号" + """ + return f"{self.app_info['name']} v{self.version}" + + def get_file_version_info(self) -> Dict[str, int]: + """获取文件版本信息(用于Windows EXE元数据) + + Returns: + dict: 包含文件版本和产品版本的高16位和低16位值的字典 + """ + return { + "file_version_ms": (self.version_info["major"] << 16) | self.version_info["minor"], + "file_version_ls": (self.version_info["patch"] << 16) | self.version_info["build"], + "product_version_ms": (self.version_info["major"] << 16) | self.version_info["minor"], + "product_version_ls": (self.version_info["patch"] << 16) | self.version_info["build"] + } + + +# 创建全局版本管理器实例 +version_manager: VersionManager = VersionManager() diff --git a/version_info.txt b/version_info.txt new file mode 100644 index 0000000000000000000000000000000000000000..681875ad06569fcad82f429b0d1a1be4c70002ac --- /dev/null +++ b/version_info.txt @@ -0,0 +1,37 @@ +VSVersionInfo( + ffi=FixedFileInfo( + filevers=(2026,2,5,1), + prodvers=(1,0,0,0), + mask=0x3f, + flags=0x0, + OS=0x4, + fileType=0x1, + subtype=0x0, + date=(0, 0) + ), + kids=[ + StringFileInfo( + [ + StringTable( + u'080404B0', + [ + StringStruct(u'CompanyName', u'浮晓 HXiao Studio'), + StringStruct(u'FileDescription', u'取色卡 - Color Card'), + StringStruct(u'FileVersion', u'2026.2.5'), + StringStruct(u'InternalName', u'Color_Card'), + StringStruct(u'LegalCopyright', u'© 2026 浮晓 HXiao Studio'), + StringStruct(u'OriginalFilename', u'Color_Card.exe'), + StringStruct(u'ProductName', u'取色卡'), + StringStruct(u'ProductVersion', u'1.0.0'), + StringStruct(u'Comments', u'图片颜色分析工具,帮助快速提取颜色信息和分析明度分布') + ] + ) + ] + ), + VarFileInfo( + [ + VarStruct(u'Translation', [2052, 1200]) + ] + ) + ] +) diff --git a/widgets/__init__.py b/widgets/__init__.py deleted file mode 100644 index 47261f1c4c6b85acc8c8ecd90f6b53987b69b87b..0000000000000000000000000000000000000000 --- a/widgets/__init__.py +++ /dev/null @@ -1,7 +0,0 @@ -from .main_window import MainWindow -from .image_canvas import ImageCanvas -from .color_picker import ColorPicker -from .color_card import ColorCard, ColorCardPanel -from .zoom_viewer import ZoomViewer - -__all__ = ['MainWindow', 'ImageCanvas', 'ColorPicker', 'ColorCard', 'ColorCardPanel', 'ZoomViewer'] diff --git a/widgets/color_card.py b/widgets/color_card.py deleted file mode 100644 index 0c33af14c6b85b8900c12ee47128f84c233e8c7b..0000000000000000000000000000000000000000 --- a/widgets/color_card.py +++ /dev/null @@ -1,146 +0,0 @@ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor - - -class ColorValueLabel(QWidget): - """显示单个颜色值的标签""" - def __init__(self, label_text, parent=None): - super().__init__(parent) - layout = QHBoxLayout(self) - layout.setContentsMargins(5, 2, 5, 2) - layout.setSpacing(5) - - self.label = QLabel(label_text) - self.label.setStyleSheet("color: #888; font-size: 11px;") - self.value = QLabel("--") - self.value.setStyleSheet("color: #333; font-size: 12px; font-weight: bold;") - - layout.addWidget(self.label) - layout.addWidget(self.value) - layout.addStretch() - - def set_value(self, value): - self.value.setText(str(value)) - - -class ColorCard(QWidget): - """单个色卡组件""" - def __init__(self, index, parent=None): - super().__init__(parent) - self.index = index - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(5) - - # 颜色块 - self.color_block = QWidget() - self.color_block.setFixedHeight(80) - self.color_block.setStyleSheet("background-color: #cccccc; border-radius: 4px;") - layout.addWidget(self.color_block) - - # 数值区域(两列布局) - values_container = QWidget() - values_layout = QHBoxLayout(values_container) - values_layout.setContentsMargins(0, 0, 0, 0) - values_layout.setSpacing(10) - - # HSB 值(左列) - hsb_container = QWidget() - hsb_layout = QVBoxLayout(hsb_container) - hsb_layout.setContentsMargins(0, 0, 0, 0) - hsb_layout.setSpacing(2) - - self.h_label = ColorValueLabel("H:") - self.s_label = ColorValueLabel("S:") - self.b_label = ColorValueLabel("B:") - - hsb_layout.addWidget(self.h_label) - hsb_layout.addWidget(self.s_label) - hsb_layout.addWidget(self.b_label) - - values_layout.addWidget(hsb_container) - - # LAB 值(右列) - lab_container = QWidget() - lab_layout = QVBoxLayout(lab_container) - lab_layout.setContentsMargins(0, 0, 0, 0) - lab_layout.setSpacing(2) - - self.l_label = ColorValueLabel("L:") - self.a_label = ColorValueLabel("a:") - self.b_lab_label = ColorValueLabel("b:") - - lab_layout.addWidget(self.l_label) - lab_layout.addWidget(self.a_label) - lab_layout.addWidget(self.b_lab_label) - - values_layout.addWidget(lab_container) - - layout.addWidget(values_container) - layout.addStretch() - - def set_color(self, color_info): - """设置颜色信息""" - # 更新颜色块 - r, g, b = color_info['rgb'] - color_str = f"rgb({r}, {g}, {b})" - self.color_block.setStyleSheet( - f"background-color: {color_str}; border-radius: 4px; border: 1px solid #ddd;" - ) - - # 更新HSB值 - h, s, b_val = color_info['hsb'] - self.h_label.set_value(f"{h}°") - self.s_label.set_value(f"{s}%") - self.b_label.set_value(f"{b_val}%") - - # 更新LAB值 - l, a, b_lab = color_info['lab'] - self.l_label.set_value(l) - self.a_label.set_value(a) - self.b_lab_label.set_value(b_lab) - - def clear_color(self): - """清空颜色,恢复默认状态""" - # 重置颜色块 - self.color_block.setStyleSheet("background-color: #cccccc; border-radius: 4px;") - - # 重置所有值为 "--" - self.h_label.set_value("--") - self.s_label.set_value("--") - self.b_label.set_value("--") - self.l_label.set_value("--") - self.a_label.set_value("--") - self.b_lab_label.set_value("--") - - -class ColorCardPanel(QWidget): - """色卡面板(包含5个色卡)""" - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(15) - - self.cards = [] - for i in range(5): - card = ColorCard(i) - self.cards.append(card) - layout.addWidget(card) - - def update_color(self, index, color_info): - """更新指定索引的颜色""" - if 0 <= index < len(self.cards): - self.cards[index].set_color(color_info) - - def clear_colors(self): - """清空所有色卡颜色""" - for card in self.cards: - card.clear_color() diff --git a/widgets/image_canvas.py b/widgets/image_canvas.py deleted file mode 100644 index 8918c18b921cd17ab6ee69deaac6c31d6d2cf641..0000000000000000000000000000000000000000 --- a/widgets/image_canvas.py +++ /dev/null @@ -1,258 +0,0 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtCore import Qt, QPoint, Signal, QRect -from PySide6.QtGui import QPainter, QPixmap, QImage, QColor, QFont - -from qfluentwidgets import RoundMenu, Action, FluentIcon - -from .color_picker import ColorPicker -from .zoom_viewer import ZoomViewer - - -class ImageCanvas(QWidget): - """图片显示画布,支持取色点拖动""" - color_picked = Signal(int, tuple) # 信号:索引, RGB颜色 - image_loaded = Signal(str) # 信号:图片路径 - open_image_requested = Signal() # 信号:请求打开图片 - change_image_requested = Signal() # 信号:请求更换图片 - clear_image_requested = Signal() # 信号:请求清空图片 - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumSize(600, 400) - self.setStyleSheet("background-color: #2a2a2a; border-radius: 8px;") - self.setCursor(Qt.CursorShape.PointingHandCursor) - - self._original_pixmap = None - self._image = None - self._pickers = [] - self._picker_positions = [] - self._zoom_viewer = None - self._active_picker_index = -1 - - # 创建放大视图 - self._zoom_viewer = ZoomViewer(self) - - # 创建5个取色点(初始隐藏) - for i in range(5): - picker = ColorPicker(i, self) - picker.position_changed.connect(self.on_picker_moved) - picker.drag_started.connect(self.on_picker_drag_started) - picker.drag_finished.connect(self.on_picker_drag_finished) - picker.hide() # 初始隐藏 - self._pickers.append(picker) - self._picker_positions.append(QPoint(100 + i * 100, 100)) - - self.update_picker_positions() - - def set_image(self, image_path): - """加载并显示图片""" - # 加载原始高分辨率图片 - self._original_pixmap = QPixmap(image_path) - self._image = QImage(image_path) - - if self._original_pixmap and not self._original_pixmap.isNull(): - # 设置放大视图的图片 - self._zoom_viewer.set_image(self._image) - - # 显示取色点 - for picker in self._pickers: - picker.show() - - # 改变光标为默认 - self.setCursor(Qt.CursorShape.ArrowCursor) - - # 重新初始化取色点位置到图片中心区域 - center_x = self.width() // 2 - center_y = self.height() // 2 - for i, picker in enumerate(self._pickers): - offset_x = (i - 2) * 50 - self._picker_positions[i] = QPoint(center_x + offset_x, center_y) - - self.update_picker_positions() - self.extract_all_colors() - self.update() - - # 发送图片加载信号 - self.image_loaded.emit(image_path) - - def update_picker_positions(self): - """更新所有取色点的位置""" - for i, picker in enumerate(self._pickers): - pos = self._picker_positions[i] - picker.move(pos.x() - picker.radius, pos.y() - picker.radius) - - def on_picker_drag_started(self, index): - """取色点开始拖动""" - self._active_picker_index = index - self._zoom_viewer.show() - self.update_zoom_viewer() - - def on_picker_drag_finished(self, index): - """取色点结束拖动""" - self._zoom_viewer.hide() - self._active_picker_index = -1 - - def on_picker_moved(self, index, new_pos): - """取色点移动时的回调""" - self._picker_positions[index] = new_pos - self.extract_color(index) - self.update_zoom_viewer() - - def update_zoom_viewer(self): - """更新放大视图的位置和内容""" - if self._active_picker_index < 0 or self._image is None: - return - - picker_pos = self._picker_positions[self._active_picker_index] - - # 更新放大视图位置 - self._zoom_viewer.update_position(picker_pos) - - # 计算原始图片中的坐标 - image_pos = self.canvas_to_image_pos(picker_pos) - if image_pos: - self._zoom_viewer.set_center_position(image_pos) - - def canvas_to_image_pos(self, canvas_pos): - """将画布坐标转换为原始图片坐标""" - if self._image is None or self._image.isNull(): - return None - - display_rect = self.get_display_rect() - if display_rect is None: - return None - - disp_x, disp_y, disp_w, disp_h = display_rect - - # 将画布坐标转换为图片坐标 - img_x = canvas_pos.x() - disp_x - img_y = canvas_pos.y() - disp_y - - # 检查坐标是否在图片显示范围内 - if 0 <= img_x < disp_w and 0 <= img_y < disp_h: - # 计算在原始图片中的坐标 - scale_x = self._image.width() / disp_w - scale_y = self._image.height() / disp_h - - orig_x = int(img_x * scale_x) - orig_y = int(img_y * scale_y) - - # 确保坐标在原始图片范围内 - orig_x = max(0, min(orig_x, self._image.width() - 1)) - orig_y = max(0, min(orig_y, self._image.height() - 1)) - - return QPoint(orig_x, orig_y) - - return None - - def get_display_rect(self): - """计算图片在画布中的显示区域""" - if self._original_pixmap is None or self._original_pixmap.isNull(): - return None - - # 计算缩放后的尺寸(保持比例) - scaled_size = self._original_pixmap.size() - scaled_size.scale(self.size(), Qt.AspectRatioMode.KeepAspectRatio) - - # 居中显示 - x = (self.width() - scaled_size.width()) // 2 - y = (self.height() - scaled_size.height()) // 2 - - return x, y, scaled_size.width(), scaled_size.height() - - def extract_color(self, index): - """提取指定取色点的颜色""" - if self._image is None or self._image.isNull(): - return - - pos = self._picker_positions[index] - image_pos = self.canvas_to_image_pos(pos) - - if image_pos: - # 获取像素颜色 - color = self._image.pixelColor(image_pos.x(), image_pos.y()) - rgb = (color.red(), color.green(), color.blue()) - - # 更新取色点显示的颜色 - self._pickers[index].set_color(color) - - # 发送信号 - self.color_picked.emit(index, rgb) - - def extract_all_colors(self): - """提取所有取色点的颜色""" - for i in range(len(self._pickers)): - self.extract_color(i) - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - - # 绘制背景 - painter.fillRect(self.rect(), QColor(42, 42, 42)) - - # 绘制图片(使用原始高分辨率图片,实时缩放显示) - if self._original_pixmap and not self._original_pixmap.isNull(): - display_rect = self.get_display_rect() - if display_rect: - x, y, w, h = display_rect - target_rect = QRect(x, y, w, h) - painter.drawPixmap(target_rect, self._original_pixmap, self._original_pixmap.rect()) - else: - # 没有图片时显示提示文字 - painter.setPen(QColor(150, 150, 150)) - font = QFont() - font.setPointSize(14) - painter.setFont(font) - text = "点击导入图片" - text_rect = painter.boundingRect(self.rect(), Qt.AlignmentFlag.AlignCenter, text) - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) - - def mousePressEvent(self, event): - """鼠标点击事件""" - if event.button() == Qt.MouseButton.LeftButton: - # 如果没有图片,点击打开文件对话框 - if self._original_pixmap is None or self._original_pixmap.isNull(): - self.open_image_requested.emit() - event.accept() - - def resizeEvent(self, event): - """窗口大小改变时重新调整图片""" - super().resizeEvent(event) - if self._image and not self._image.isNull(): - # 窗口大小改变时,只需重新提取颜色(因为显示区域变了) - self.extract_all_colors() - self.update() - - def contextMenuEvent(self, event): - """右键菜单事件""" - # 只有在有图片时才显示右键菜单 - if self._original_pixmap is None or self._original_pixmap.isNull(): - return - - menu = RoundMenu("") - - change_action = Action(FluentIcon.PHOTO, "更换图片") - change_action.triggered.connect(self.change_image_requested.emit) - menu.addAction(change_action) - - clear_action = Action(FluentIcon.DELETE, "清空图片") - clear_action.triggered.connect(self.clear_image_requested.emit) - menu.addAction(clear_action) - - menu.exec(event.globalPos()) - - def clear_image(self): - """清空图片""" - self._original_pixmap = None - self._image = None - - # 隐藏取色点和放大视图 - for picker in self._pickers: - picker.hide() - self._zoom_viewer.hide() - - # 恢复光标为手型 - self.setCursor(Qt.CursorShape.PointingHandCursor) - - self.update() diff --git a/widgets/luminance_canvas.py b/widgets/luminance_canvas.py deleted file mode 100644 index be7ee85c350186ce1584feb3d61bb046a1709662..0000000000000000000000000000000000000000 --- a/widgets/luminance_canvas.py +++ /dev/null @@ -1,305 +0,0 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtCore import Qt, QPoint, Signal, QRect -from PySide6.QtGui import QPainter, QPixmap, QImage, QColor, QFont - -from qfluentwidgets import RoundMenu, Action, FluentIcon - -from .color_picker import ColorPicker -from color_utils import rgb_to_luminance, get_zone_from_luminance - - -class LuminanceCanvas(QWidget): - """明度提取画布 - 支持取色点拖动和Zone标注显示""" - luminance_picked = Signal(int, int, int) # 信号:索引, Zone, 明度值 - image_loaded = Signal(str) # 信号:图片路径 - open_image_requested = Signal() # 信号:请求打开图片 - change_image_requested = Signal() # 信号:请求更换图片 - clear_image_requested = Signal() # 信号:请求清空图片 - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumSize(600, 400) - self.setStyleSheet("background-color: #2a2a2a; border-radius: 8px;") - self.setCursor(Qt.CursorShape.PointingHandCursor) - - self._original_pixmap = None - self._image = None - self._pickers = [] - self._picker_positions = [] - self._active_picker_index = -1 - self._picker_zones = [-1] * 5 # 存储每个取色点的Zone - - # 创建5个取色点(初始隐藏) - for i in range(5): - picker = ColorPicker(i, self) - picker.position_changed.connect(self.on_picker_moved) - picker.drag_started.connect(self.on_picker_drag_started) - picker.drag_finished.connect(self.on_picker_drag_finished) - picker.hide() # 初始隐藏 - self._pickers.append(picker) - self._picker_positions.append(QPoint(100 + i * 100, 100)) - - self.update_picker_positions() - - def set_image(self, image_path): - """加载并显示图片""" - # 加载原始高分辨率图片 - self._original_pixmap = QPixmap(image_path) - self._image = QImage(image_path) - - if self._original_pixmap and not self._original_pixmap.isNull(): - # 显示取色点 - for picker in self._pickers: - picker.show() - - # 改变光标为默认 - self.setCursor(Qt.CursorShape.ArrowCursor) - - # 重新初始化取色点位置到图片中心区域 - center_x = self.width() // 2 - center_y = self.height() // 2 - for i, picker in enumerate(self._pickers): - offset_x = (i - 2) * 50 - self._picker_positions[i] = QPoint(center_x + offset_x, center_y) - - self.update_picker_positions() - self.extract_all_luminance() - self.update() - - # 发送图片加载信号 - self.image_loaded.emit(image_path) - - def update_picker_positions(self): - """更新所有取色点的位置""" - for i, picker in enumerate(self._pickers): - pos = self._picker_positions[i] - picker.move(pos.x() - picker.radius, pos.y() - picker.radius) - - def on_picker_drag_started(self, index): - """取色点开始拖动""" - self._active_picker_index = index - # 设置其他取色点为非活动状态 - for i, picker in enumerate(self._pickers): - picker.set_active(i == index) - - def on_picker_drag_finished(self, index): - """取色点结束拖动""" - self._active_picker_index = -1 - # 所有取色点恢复默认状态 - for picker in self._pickers: - picker.set_active(False) - - def on_picker_moved(self, index, new_pos): - """取色点移动时的回调""" - self._picker_positions[index] = new_pos - self.extract_luminance(index) - self.update() - - def canvas_to_image_pos(self, canvas_pos): - """将画布坐标转换为原始图片坐标""" - if self._image is None or self._image.isNull(): - return None - - display_rect = self.get_display_rect() - if display_rect is None: - return None - - disp_x, disp_y, disp_w, disp_h = display_rect - - # 将画布坐标转换为图片坐标 - img_x = canvas_pos.x() - disp_x - img_y = canvas_pos.y() - disp_y - - # 检查坐标是否在图片显示范围内 - if 0 <= img_x < disp_w and 0 <= img_y < disp_h: - # 计算在原始图片中的坐标 - scale_x = self._image.width() / disp_w - scale_y = self._image.height() / disp_h - - orig_x = int(img_x * scale_x) - orig_y = int(img_y * scale_y) - - # 确保坐标在原始图片范围内 - orig_x = max(0, min(orig_x, self._image.width() - 1)) - orig_y = max(0, min(orig_y, self._image.height() - 1)) - - return QPoint(orig_x, orig_y) - - return None - - def get_display_rect(self): - """计算图片在画布中的显示区域""" - if self._original_pixmap is None or self._original_pixmap.isNull(): - return None - - # 计算缩放后的尺寸(保持比例) - scaled_size = self._original_pixmap.size() - scaled_size.scale(self.size(), Qt.AspectRatioMode.KeepAspectRatio) - - # 居中显示 - x = (self.width() - scaled_size.width()) // 2 - y = (self.height() - scaled_size.height()) // 2 - - return x, y, scaled_size.width(), scaled_size.height() - - def extract_luminance(self, index): - """提取指定取色点的明度信息""" - if self._image is None or self._image.isNull(): - return - - pos = self._picker_positions[index] - image_pos = self.canvas_to_image_pos(pos) - - if image_pos: - # 获取像素颜色 - color = self._image.pixelColor(image_pos.x(), image_pos.y()) - rgb = (color.red(), color.green(), color.blue()) - - # 计算明度和Zone - luminance = rgb_to_luminance(*rgb) - zone = get_zone_from_luminance(luminance) - - # 存储Zone信息 - self._picker_zones[index] = zone - - # 发送信号 - self.luminance_picked.emit(index, zone, luminance) - - def extract_all_luminance(self): - """提取所有取色点的明度""" - for i in range(len(self._pickers)): - self.extract_luminance(i) - - def get_zone_label(self, zone): - """获取Zone的显示标签""" - if zone < 0: - return "--" - return str(zone) - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.SmoothPixmapTransform) - - # 绘制背景 - painter.fillRect(self.rect(), QColor(42, 42, 42)) - - # 绘制图片(使用原始高分辨率图片,实时缩放显示) - if self._original_pixmap and not self._original_pixmap.isNull(): - display_rect = self.get_display_rect() - if display_rect: - x, y, w, h = display_rect - target_rect = QRect(x, y, w, h) - painter.drawPixmap(target_rect, self._original_pixmap, self._original_pixmap.rect()) - - # 绘制Zone标注(白色框+黑字) - self._draw_zone_labels(painter) - else: - # 没有图片时显示提示文字 - painter.setPen(QColor(150, 150, 150)) - font = QFont() - font.setPointSize(14) - painter.setFont(font) - text = "点击导入图片" - text_rect = painter.boundingRect(self.rect(), Qt.AlignmentFlag.AlignCenter, text) - painter.drawText(text_rect, Qt.AlignmentFlag.AlignCenter, text) - - def _draw_zone_labels(self, painter): - """绘制Zone标注 - 白色背景框 + 黑色文字""" - if not self._image or self._image.isNull(): - return - - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - font = QFont() - font.setPointSize(10) - font.setBold(True) - painter.setFont(font) - - for i, pos in enumerate(self._picker_positions): - zone = self._picker_zones[i] - if zone < 0: - continue - - label = self.get_zone_label(zone) - - # 计算文字大小 - text_rect = painter.boundingRect(0, 0, 0, 0, Qt.AlignmentFlag.AlignCenter, label) - padding = 4 - box_width = text_rect.width() + padding * 2 - box_height = text_rect.height() + padding * 2 - - # 计算标注位置(取色点右上方) - box_x = pos.x() + 15 - box_y = pos.y() - 25 - - # 确保不超出画布边界 - if box_x + box_width > self.width(): - box_x = pos.x() - box_width - 15 - if box_y < 0: - box_y = pos.y() + 15 - - # 绘制白色背景框 - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(255, 255, 255)) - painter.drawRoundedRect(box_x, box_y, box_width, box_height, 3, 3) - - # 绘制黑色文字 - painter.setPen(QColor(0, 0, 0)) - text_x = box_x + padding - text_y = box_y + padding - painter.drawText(text_x, text_y, text_rect.width(), text_rect.height(), - Qt.AlignmentFlag.AlignCenter, label) - - def mousePressEvent(self, event): - """鼠标点击事件""" - if event.button() == Qt.MouseButton.LeftButton: - # 如果没有图片,点击打开文件对话框 - if self._original_pixmap is None or self._original_pixmap.isNull(): - self.open_image_requested.emit() - event.accept() - - def resizeEvent(self, event): - """窗口大小改变时重新调整图片""" - super().resizeEvent(event) - if self._image and not self._image.isNull(): - # 窗口大小改变时,只需重新提取明度(因为显示区域变了) - self.extract_all_luminance() - self.update() - - def contextMenuEvent(self, event): - """右键菜单事件""" - # 只有在有图片时才显示右键菜单 - if self._original_pixmap is None or self._original_pixmap.isNull(): - return - - menu = RoundMenu("") - - change_action = Action(FluentIcon.PHOTO, "更换图片") - change_action.triggered.connect(self.change_image_requested.emit) - menu.addAction(change_action) - - clear_action = Action(FluentIcon.DELETE, "清空图片") - clear_action.triggered.connect(self.clear_image_requested.emit) - menu.addAction(clear_action) - - menu.exec(event.globalPos()) - - def clear_image(self): - """清空图片""" - self._original_pixmap = None - self._image = None - self._picker_zones = [-1] * 5 - - # 隐藏取色点 - for picker in self._pickers: - picker.hide() - picker.set_active(False) - - # 恢复光标为手型 - self.setCursor(Qt.CursorShape.PointingHandCursor) - - self.update() - - def get_image(self): - """获取当前图片""" - return self._image diff --git a/widgets/luminance_card.py b/widgets/luminance_card.py deleted file mode 100644 index 47a321e112799faea36622e5fd421462db313130..0000000000000000000000000000000000000000 --- a/widgets/luminance_card.py +++ /dev/null @@ -1,113 +0,0 @@ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QHBoxLayout, QLabel -from PySide6.QtCore import Qt -from PySide6.QtGui import QColor - - -class ZoneValueLabel(QWidget): - """显示Zone值的标签 - 白色背景框 + 黑色文字""" - def __init__(self, parent=None): - super().__init__(parent) - self.setFixedSize(50, 30) - self._zone = -1 - self._luminance = 0 - - def set_zone(self, zone: int, luminance: int = 0): - """设置Zone值""" - self._zone = zone - self._luminance = luminance - self.update() - - def clear(self): - """清空显示""" - self._zone = -1 - self._luminance = 0 - self.update() - - def get_zone_label(self) -> str: - """获取Zone显示标签""" - if self._zone < 0: - return "--" - return str(self._zone) - - def paintEvent(self, event): - from PySide6.QtGui import QPainter, QFont - - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # 白色背景框 - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(QColor(255, 255, 255)) - painter.drawRoundedRect(0, 0, self.width(), self.height(), 4, 4) - - # 黑色文字 - painter.setPen(QColor(0, 0, 0)) - font = QFont() - font.setPointSize(12) - font.setBold(True) - painter.setFont(font) - - label = self.get_zone_label() - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, label) - - -class LuminanceCard(QWidget): - """单个明度信息卡 - 简化版,只显示Zone""" - def __init__(self, index, parent=None): - super().__init__(parent) - self.index = index - self.setup_ui() - - def setup_ui(self): - layout = QVBoxLayout(self) - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(10) - layout.setAlignment(Qt.AlignmentFlag.AlignCenter) - - # Zone显示框 - self.zone_label = ZoneValueLabel() - layout.addWidget(self.zone_label, alignment=Qt.AlignmentFlag.AlignCenter) - - # 索引标签 - index_label = QLabel(f"#{self.index + 1}") - index_label.setStyleSheet("color: #888; font-size: 11px;") - index_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - layout.addWidget(index_label) - - layout.addStretch() - - def set_zone(self, zone: int, luminance: int = 0): - """设置Zone信息""" - self.zone_label.set_zone(zone, luminance) - - def clear(self): - """清空显示""" - self.zone_label.clear() - - -class LuminanceCardPanel(QWidget): - """明度信息卡面板(包含5个Zone卡)""" - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - - def setup_ui(self): - layout = QHBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(15) - - self.cards = [] - for i in range(5): - card = LuminanceCard(i) - self.cards.append(card) - layout.addWidget(card) - - def update_zone(self, index: int, zone: int, luminance: int = 0): - """更新指定索引的Zone""" - if 0 <= index < len(self.cards): - self.cards[index].set_zone(zone, luminance) - - def clear_all(self): - """清空所有卡片""" - for card in self.cards: - card.clear() diff --git a/widgets/luminance_histogram.py b/widgets/luminance_histogram.py deleted file mode 100644 index 7766e233f50e8af6ed37a60278e63965a5dba3bb..0000000000000000000000000000000000000000 --- a/widgets/luminance_histogram.py +++ /dev/null @@ -1,206 +0,0 @@ -from PySide6.QtWidgets import QWidget -from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QPainter, QColor, QPen, QFont, QImage - - -class LuminanceHistogram(QWidget): - """明度直方图组件 - 显示图片的明度分布和Zone分区""" - - zone_changed = Signal(int) # 信号:当前Zone变化 - - def __init__(self, parent=None): - super().__init__(parent) - self.setMinimumHeight(150) - self.setMaximumHeight(200) - self.setStyleSheet("background-color: #1a1a1a; border-radius: 4px;") - - self._histogram_data = [0] * 256 # 256个亮度值的像素计数 - self._max_count = 0 - self._current_zone = -1 # 当前选中的Zone - self._image = None - - # Zone颜色配置 - self._zone_colors = [ - QColor(20, 20, 20), # Zone 0: 极暗 - QColor(60, 60, 60), # Zone 1: 暗 - QColor(100, 100, 100), # Zone 2: 偏暗 - QColor(140, 140, 140), # Zone 3: 中灰 - QColor(180, 180, 180), # Zone 4: 偏亮 - QColor(210, 210, 210), # Zone 5: 亮 - QColor(235, 235, 235), # Zone 6: 很亮 - QColor(255, 255, 255), # Zone 7: 极亮 - ] - self._highlight_color = QColor(0, 150, 255) # 高亮颜色 - - def set_image(self, image: QImage): - """设置图片并计算直方图""" - self._image = image - self._calculate_histogram() - self.update() - - def _calculate_histogram(self): - """计算明度直方图""" - self._histogram_data = [0] * 256 - self._max_count = 0 - - if self._image is None or self._image.isNull(): - return - - width = self._image.width() - height = self._image.height() - - # 遍历所有像素计算明度 - for y in range(height): - for x in range(width): - color = self._image.pixelColor(x, y) - # 使用相对亮度公式 - luminance = int(0.299 * color.red() + - 0.587 * color.green() + - 0.114 * color.blue()) - luminance = max(0, min(255, luminance)) - self._histogram_data[luminance] += 1 - - self._max_count = max(self._histogram_data) if self._histogram_data else 1 - - def set_current_zone(self, zone: int): - """设置当前选中的Zone (0-7)""" - if 0 <= zone <= 7 and zone != self._current_zone: - self._current_zone = zone - self.zone_changed.emit(zone) - self.update() - - def clear(self): - """清空直方图""" - self._histogram_data = [0] * 256 - self._max_count = 0 - self._current_zone = -1 - self._image = None - self.update() - - def get_zone_from_luminance(self, luminance: int) -> int: - """根据明度值获取Zone (0-7)""" - return min(7, luminance // 32) - - def get_zone_label(self, zone: int) -> str: - """获取Zone的显示标签""" - labels = ["0", "1", "2", "3", "4", "5", "6", "7"] - return labels[zone] if 0 <= zone <= 7 else "--" - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.RenderHint.Antialiasing) - - # 绘制背景 - painter.fillRect(self.rect(), QColor(26, 26, 26)) - - if self._max_count == 0: - # 没有数据时显示提示 - painter.setPen(QColor(100, 100, 100)) - font = QFont() - font.setPointSize(10) - painter.setFont(font) - painter.drawText(self.rect(), Qt.AlignmentFlag.AlignCenter, "导入图片以查看直方图") - return - - # 计算绘制区域 - margin_left = 30 - margin_right = 10 - margin_top = 25 - margin_bottom = 25 - - chart_width = self.width() - margin_left - margin_right - chart_height = self.height() - margin_top - margin_bottom - - # 绘制Zone背景分区 - zone_width = chart_width / 8 - for i in range(8): - x = margin_left + i * zone_width - # 如果是当前选中的Zone,使用高亮颜色 - if i == self._current_zone: - painter.fillRect( - int(x), margin_top, - int(zone_width), chart_height, - QColor(0, 100, 150, 80) - ) - else: - # 轻微显示Zone背景 - painter.fillRect( - int(x), margin_top, - int(zone_width), chart_height, - QColor(40, 40, 40, 30) - ) - - # 绘制直方图柱状图 - bar_width = chart_width / 256 - - for i in range(256): - count = self._histogram_data[i] - if count > 0: - # 使用对数缩放使细节更明显 - normalized = count / self._max_count - bar_height = normalized * chart_height - - x = margin_left + i * bar_width - y = margin_top + chart_height - bar_height - - # 根据Zone选择颜色 - zone = self.get_zone_from_luminance(i) - if zone == self._current_zone: - color = self._highlight_color - else: - # 在Zone内渐变 - zone_start = zone * 32 - zone_progress = (i - zone_start) / 32 - base_color = self._zone_colors[zone] - color = base_color - - painter.setPen(Qt.PenStyle.NoPen) - painter.setBrush(color) - painter.drawRect(int(x), int(y), max(1, int(bar_width) + 1), int(bar_height)) - - # 绘制Zone分隔线 - painter.setPen(QPen(QColor(80, 80, 80), 1)) - for i in range(1, 8): - x = margin_left + i * zone_width - painter.drawLine(int(x), margin_top, int(x), margin_top + chart_height) - - # 绘制边框 - painter.setPen(QPen(QColor(60, 60, 60), 1)) - painter.setBrush(Qt.BrushStyle.NoBrush) - painter.drawRect(margin_left, margin_top, chart_width, chart_height) - - # 绘制Zone标签 - painter.setPen(QColor(150, 150, 150)) - font = QFont() - font.setPointSize(8) - painter.setFont(font) - - for i in range(8): - x = margin_left + i * zone_width + zone_width / 2 - label = self.get_zone_label(i) - text_rect = painter.boundingRect(0, 0, 0, 0, Qt.AlignmentFlag.AlignCenter, label) - text_x = int(x - text_rect.width() / 2) - text_y = self.height() - 8 - painter.drawText(text_x, text_y, label) - - # 绘制Y轴标签(最小值和最大值) - painter.setPen(QColor(100, 100, 100)) - font.setPointSize(7) - painter.setFont(font) - painter.drawText(5, margin_top + 5, "max") - painter.drawText(5, margin_top + chart_height, "0") - - # 绘制标题 - painter.setPen(QColor(200, 200, 200)) - font.setPointSize(9) - font.setBold(True) - painter.setFont(font) - painter.drawText(margin_left, 18, "Zone System Histogram") - - # 如果当前有选中的Zone,显示信息 - if self._current_zone >= 0: - painter.setPen(self._highlight_color) - font.setPointSize(10) - painter.setFont(font) - zone_text = f"Zone {self._current_zone}" - painter.drawText(self.width() - 80, 18, zone_text) diff --git a/widgets/main_window.py b/widgets/main_window.py deleted file mode 100644 index b36fbb94b93ffd2ed07b706701a231553587d8c7..0000000000000000000000000000000000000000 --- a/widgets/main_window.py +++ /dev/null @@ -1,111 +0,0 @@ -from PySide6.QtWidgets import QWidget, QVBoxLayout, QSplitter -from PySide6.QtCore import Qt -from PySide6.QtGui import QAction - -from qfluentwidgets import FluentWindow, NavigationItemPosition, qrouter, FluentIcon - -from .image_canvas import ImageCanvas -from .color_card import ColorCardPanel -from color_utils import get_color_info - - -class ColorExtractInterface(QWidget): - """色彩提取界面""" - - def __init__(self, parent=None): - super().__init__(parent) - self.setup_ui() - self.setup_connections() - - def setup_ui(self): - """设置界面布局""" - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 10, 10, 10) - layout.setSpacing(10) - - splitter = QSplitter(Qt.Orientation.Vertical) - layout.addWidget(splitter, stretch=1) - - self.image_canvas = ImageCanvas() - splitter.addWidget(self.image_canvas) - - self.color_card_panel = ColorCardPanel() - self.color_card_panel.setMaximumHeight(280) - splitter.addWidget(self.color_card_panel) - - splitter.setSizes([500, 200]) - - def setup_connections(self): - """设置信号连接""" - self.image_canvas.color_picked.connect(self.on_color_picked) - self.image_canvas.image_loaded.connect(self.on_image_loaded) - self.image_canvas.open_image_requested.connect(self.open_image) - self.image_canvas.change_image_requested.connect(self.open_image) - self.image_canvas.clear_image_requested.connect(self.clear_image) - - def open_image(self): - """打开图片文件""" - from PySide6.QtWidgets import QFileDialog - file_path, _ = QFileDialog.getOpenFileName( - self, - "选择图片", - "", - "图片文件 (*.png *.jpg *.jpeg *.bmp *.gif)" - ) - - if file_path: - self.image_canvas.set_image(file_path) - - def on_image_loaded(self, file_path): - """图片加载完成回调""" - window = self.window() - if window: - window.setWindowTitle(f"Color Extractor - {file_path.split('/')[-1]}") - - def on_color_picked(self, index, rgb): - """颜色提取回调""" - color_info = get_color_info(*rgb) - self.color_card_panel.update_color(index, color_info) - - def clear_image(self): - """清空图片""" - self.image_canvas.clear_image() - self.color_card_panel.clear_colors() - - # 重置窗口标题 - window = self.window() - if window: - window.setWindowTitle("Color Extractor - 图片颜色提取器") - - -class MainWindow(FluentWindow): - """主窗口""" - - def __init__(self): - super().__init__() - self.setWindowTitle("Color Extractor - 图片颜色提取器") - self.setMinimumSize(800, 550) - self.resize(940, 660) - - self.create_sub_interface() - self.setup_navigation() - - def create_sub_interface(self): - """创建子界面""" - self.color_extract_interface = ColorExtractInterface(self) - self.color_extract_interface.setObjectName('colorExtract') - qrouter.setDefaultRouteKey(self.stackedWidget, 'colorExtract') - self.stackedWidget.addWidget(self.color_extract_interface) - - def setup_navigation(self): - """设置导航栏""" - self.addSubInterface( - self.color_extract_interface, - FluentIcon.PALETTE, - "色彩提取", - position=NavigationItemPosition.TOP - ) - - def open_image(self): - """打开图片""" - self.color_extract_interface.open_image() diff --git "a/\345\274\200\345\217\221\350\247\204\350\214\203.md" "b/\345\274\200\345\217\221\350\247\204\350\214\203.md" index de3e5ad4f6955690109c498e6ea16c79bb1e6d98..9ade3cf575ffe84786272488afb9c4da7e90e0fc 100644 --- "a/\345\274\200\345\217\221\350\247\204\350\214\203.md" +++ "b/\345\274\200\345\217\221\350\247\204\350\214\203.md" @@ -1,41 +1,57 @@ -# Color Extractor 开发规范 +# Color Card 开发规范 ## 1. 概述 -本规范旨在指导 Color Extractor 图片颜色提取器项目的后续开发,确保代码的一致性、可维护性和可扩展性。 - ### 1.1 项目简介 -Color Extractor 是一个基于 PySide6 开发的 GUI 程序,用于从图片中提取颜色,显示 HSB 和 LAB 颜色值。核心功能包括: +Color Card(取色卡)是一个基于 PySide6 开发的图片颜色分析工具,用于从图片中提取颜色信息和分析明度分布,支持多种色彩模式显示。 +**核心功能:** - 图片导入(JPG、PNG、BMP、GIF) -- 5个可拖动取色点 -- 实时颜色提取和显示 -- HSB 和 LAB 双列显示 +- 色彩提取:5个可拖动取色点,实时显示色彩值 +- 多色彩模式:HSB、LAB、HSL、CMYK、RGB +- 明度提取与直方图可视化 +- 双面板数据同步 ### 1.2 开发环境 - **操作系统**:Windows 10/11 -- **Python 版本**:3.x +- **Python 版本**:3.11+ - **GUI 框架**:PySide6 + PySide6-Fluent-Widgets - **推荐 IDE**:VS Code、PyCharm、Trae ### 1.3 项目结构 ``` -d:\青山公仔\应用\Py测试\取色卡\ -├── main.py # 程序入口 -├── requirements.txt # 项目依赖 -├── color_utils.py # 颜色转换工具(RGB ↔ HSB/LAB) -├── README.md # 项目文档 -├── 开发规范.md # 本文件 -└── widgets/ - ├── __init__.py # 统一导出接口 - ├── main_window.py # 主窗口(FluentWindow) - ├── image_canvas.py # 图片画布(核心组件) - ├── color_picker.py # 取色点组件 - ├── color_card.py # 色卡面板 - └── zoom_viewer.py # 放大视图组件(拖动时显示) +color_card/ +├── main.py # 程序入口 +├── version.py # 版本管理模块 +├── requirements.txt # 项目依赖 +├── README.md # 项目文档 +├── LICENSE # 开源许可证 +├── 开发规范.md # 本文件 +├── core/ # 核心功能模块目录 +│ ├── __init__.py +│ ├── color.py # 颜色处理模块(颜色转换、明度计算) +│ └── config.py # 配置管理模块 +├── ui/ # UI模块目录(扁平化结构) +│ ├── __init__.py # 统一导出接口 +│ ├── main_window.py # 主窗口类 +│ ├── canvases.py # 画布模块(BaseCanvas、ImageCanvas、LuminanceCanvas) +│ ├── cards.py # 卡片组件模块(ColorCard、LuminanceCard) +│ ├── histograms.py # 直方图组件模块 +│ ├── color_picker.py # 颜色选择器模块 +│ ├── color_wheel.py # 颜色轮模块 +│ ├── zoom_viewer.py # 缩放查看器模块 +│ └── interfaces.py # 界面面板模块 +├── dialogs/ # 对话框模块目录 +│ ├── __init__.py +│ ├── about_dialog.py # 关于对话框 +│ └── update_dialog.py # 更新检查对话框 +└── utils/ # 工具函数模块目录 + ├── __init__.py + ├── icon.py # 图标工具模块 + └── platform.py # 平台相关工具模块 ``` --- @@ -46,38 +62,69 @@ d:\青山公仔\应用\Py测试\取色卡\ - 采用小写字母和下划线命名,如 `color_utils.py` - 核心功能模块使用清晰的描述性名称 -- 组件文件应反映其功能,如 `color_picker.py` +- 合并后的模块使用复数形式命名,如 `cards.py`、`histograms.py`、`canvases.py` ### 2.2 模块划分 | 模块类型 | 职责 | 示例文件 | |:---:|:---:|:---:| -| 入口模块 | 应用程序入口,初始化 QApplication | `main.py` | -| 工具模块 | 颜色空间转换等通用功能 | `color_utils.py` | -| 窗口模块 | 主窗口实现 | `main_window.py` | -| 画布模块 | 图片显示和取色点管理 | `image_canvas.py` | -| 组件模块 | 可复用的 UI 组件 | `color_picker.py`, `color_card.py` | +| 入口模块 | 应用程序入口 | `main.py` | +| 核心模块 | 颜色处理、配置管理 | `core/color.py`, `core/config.py` | +| UI 模块 | 界面组件和面板 | `ui/canvases.py`, `ui/cards.py` | +| 对话框模块 | 弹出对话框 | `dialogs/about_dialog.py` | +| 工具模块 | 通用功能 | `utils/icon.py`, `utils/platform.py` | + +### 2.3 目录结构原则 + +**扁平化结构(2级目录):** -### 2.3 导入规范 +``` +color_card/ ← 第1级 +├── core/ ← 第2级 +├── ui/ ← 第2级(扁平化,直接包含所有UI文件) +├── dialogs/ ← 第2级 +└── utils/ ← 第2级 +``` -- 按模块类型分组导入:标准库 → 第三方库 → 项目模块 -- 使用绝对导入,保持清晰 -- 避免循环导入 +**设计原则:** +- 避免过度嵌套(不超过2级目录) +- 将紧密相关的类合并到同一文件,减少文件数量 +- 保持功能模块化,职责明确 + +### 2.4 导入规范 + +**导入顺序:** 标准库 → 第三方库 → 项目模块 + +**规范要求:** +- 按模块类型分组导入,添加清晰的分组注释 +- 定期清理未使用的导入 +- 合并同一模块的多次导入 +- 优先使用绝对导入 ```python # 标准库导入 import sys import math +from pathlib import Path # 第三方库导入 from PySide6.QtWidgets import QApplication, QMainWindow from PySide6.QtCore import Qt, Signal -from PySide6.QtGui import QPainter, QColor -from qfluentwidgets import FluentWindow, setTheme, Theme, setThemeColor, FluentIcon +from qfluentwidgets import FluentWindow # 项目模块导入 -from widgets import MainWindow -from color_utils import get_color_info +from core import get_color_info, get_config_manager +from ui import MainWindow +``` + +**导入清理示例:** +```python +# 修改前 +from styles import UnifiedStyleHelper +from styles import get_global_font_manager + +# 修改后 +from styles import UnifiedStyleHelper, get_global_font_manager ``` --- @@ -89,346 +136,435 @@ from color_utils import get_color_info - 遵循 **PEP 8** 代码风格规范 - 使用 4 个空格缩进 - 行长度限制在 100 字符以内 -- 使用清晰、有意义的变量和函数命名 +- 保持代码简洁,删除无用的调试代码和临时测试代码 + +### 3.2 代码清理原则 + +**修改代码时应同步进行以下清理:** +- 删除未使用的变量、函数、导入和注释 +- 合并重复的逻辑和相似的功能 +- 检查并清除相关的重复冗余代码 +- 保持代码整洁,提高代码复用性 -### 3.2 命名规范 +**清理示例:** +```python +# 修改前 +import json +import re + +def calculate(): + temp = 0 # 未使用的变量 + return result + +# 修改后(删除未使用的导入和变量) +def calculate(): + return result +``` + +### 3.3 命名规范 | 类型 | 规范 | 示例 | |:---:|:---:|:---:| -| 类名 | 驼峰命名法 (CamelCase) | `ColorPicker`, `ImageCanvas` | -| 函数/方法 | 小写+下划线 | `extract_color()`, `update_picker_positions()` | -| 变量 | 小写+下划线 | `picker_positions`, `original_pixmap` | +| 类名 | 驼峰命名法 | `ColorPicker`, `ImageCanvas` | +| 函数/方法 | 小写+下划线 | `extract_color()` | +| 变量 | 小写+下划线 | `picker_positions` | | 常量 | 大写+下划线 | `PICKER_RADIUS = 12` | -| 私有属性 | 单下划线前缀 | `_dragging`, `_color` | +| 私有属性 | 单下划线前缀 | `_dragging` | -### 3.3 文档字符串规范 +### 3.4 异常处理规范 -- 所有公共类和方法必须添加文档字符串 -- 使用简洁的中文描述 -- 复杂逻辑添加行内注释 +**基本原则:** +- 避免使用裸 `except:` 或 `except Exception:` +- 应指定具体异常类型 +- 提供详细的错误信息,便于调试 +**示例:** ```python -def rgb_to_hsb(r, g, b): - """将RGB转换为HSB (Hue, Saturation, Brightness)""" - # 归一化到 0-1 范围 - r, g, b = r / 255.0, g / 255.0, b / 255.0 - h, s, v = colorsys.rgb_to_hsv(r, g, b) - return h * 360, s * 100, v * 100 +# 错误示例 +except Exception: + pass + +# 正确示例 +except (OSError, ValueError) as e: + error_msg = f"文件读取失败: {str(e)}" + print(error_msg) +``` +### 3.5 文档字符串规范 + +**基本原则:** +- 所有公共类和方法必须添加文档字符串 +- 使用简洁的中文描述,避免冗余 +- 类文档字符串保持简洁(单行或简短段落) +- 方法文档字符串包含 Args、Returns 说明 +**精简示例:** +```python +# 类文档字符串(简洁) class ImageCanvas(QWidget): """图片显示画布,支持取色点拖动""" + pass + +# 方法文档字符串(完整但简洁) +def set_image(self, image_path): + """加载并显示图片 + + Args: + image_path: 图片文件的完整路径 + """ + pass + +# 带返回值的文档字符串 +def get_color_info(self, r, g, b): + """获取颜色信息 - color_picked = pyqtSignal(int, tuple) # 信号:索引, RGB颜色 + Args: + r: 红色通道值 (0-255) + g: 绿色通道值 (0-255) + b: 蓝色通道值 (0-255) - def set_image(self, image_path): - """加载并显示图片 - - Args: - image_path: 图片文件的完整路径 - """ - # 实现代码... + Returns: + dict: 包含RGB、HSB、LAB、HEX颜色信息的字典 + """ + pass ``` -### 3.4 信号命名规范 +### 3.6 信号命名规范 - 信号名使用小写+下划线 -- 信号名应描述动作或状态变化 - 添加注释说明信号参数 ```python class ImageCanvas(QWidget): - color_picked = pyqtSignal(int, tuple) # 信号:索引, RGB颜色 - image_loaded = pyqtSignal(str) # 信号:图片路径 - position_changed = pyqtSignal(int, QPoint) # 信号:索引, 新位置 + color_picked = Signal(int, tuple) # 信号:索引, RGB颜色 + image_loaded = Signal(str) # 信号:图片路径 + image_cleared = Signal() # 信号:图片已清空 +``` + +--- + +## 4. 基类设计规范 + +### 4.1 画布基类 (BaseCanvas) + +**文件位置:** `ui/canvases.py` + +**职责:** +- 提供图片加载、显示的基础功能 +- 实现坐标转换(画布坐标 ↔ 图片坐标) +- 管理图片相对坐标系统 +- 提供右键菜单框架 + +**子类必须实现的方法:** +```python +def _on_image_loaded(self): + """图片加载后的处理(子类重写)""" + pass + +def _on_image_cleared(self): + """图片清空后的处理(子类重写)""" + pass + +def _draw_overlay(self, painter: QPainter): + """绘制叠加内容(子类必须实现)""" + raise NotImplementedError("子类必须实现 _draw_overlay 方法") +``` + +### 4.2 卡片基类 (BaseCard / BaseCardPanel) + +**文件位置:** `ui/cards.py` + +**职责:** +- 提供统一的卡片接口 +- 管理卡片列表(BaseCardPanel) +- 支持批量清空卡片 + +**设计原则:** +- 保持接口简洁(setup_ui, clear) +- 灵活的卡片创建方法 +- 清晰的职责分离 + +### 4.3 直方图基类 (BaseHistogram) + +**文件位置:** `ui/histograms.py` + +**职责:** +- 提供通用的数据管理(set_data, clear) +- 提供通用的绘制框架(paintEvent) +- 定义抽象方法接口 + +**子类必须实现的方法:** +```python +def _draw_histogram(self, painter: QPainter): + """绘制直方图(子类必须实现)""" + raise NotImplementedError("子类必须实现 _draw_histogram 方法") + +def _draw_custom_overlay(self, painter: QPainter): + """绘制自定义叠加内容(子类重写)""" + pass + +def _draw_labels(self, painter: QPainter): + """绘制标签(子类重写)""" + pass ``` +### 4.4 基类设计原则 + +1. **单一职责**:每个基类只负责一类功能 +2. **接口清晰**:抽象方法明确,子类职责清晰 +3. **可扩展性**:便于添加新的子类实现 +4. **代码复用**:提取公共逻辑,消除重复代码 + --- -## 4. PyQt6 开发规范 +## 5. PySide6 开发规范 -### 4.1 PySide6-Fluent-Widgets 使用规范 +### 5.1 Fluent Widgets 使用规范 -- 主窗口继承 `FluentWindow` 而非 `QMainWindow` -- 使用 `setTheme()` 设置主题(Theme.AUTO / Theme.LIGHT / Theme.DARK) +- 主窗口继承 `FluentWindow` +- 使用 `setTheme()` 设置主题 - 使用 `setThemeColor()` 设置主题色 -- 使用 `addSubInterface()` 添加导航界面 -- 使用 `qrouter` 管理页面路由 ```python -from qfluentwidgets import FluentWindow, setTheme, Theme, setThemeColor, FluentIcon, NavigationItemPosition, qrouter +from qfluentwidgets import FluentWindow, setTheme, Theme, FluentIcon -# 设置主题 setTheme(Theme.AUTO) setThemeColor('#0078d4') -# 创建主窗口 class MainWindow(FluentWindow): - def __init__(self): - super().__init__() - self.create_sub_interface() - self.setup_navigation() - - def create_sub_interface(self): - """创建子界面""" - self.interface = QWidget() - self.interface.setObjectName('interface') - self.stackedWidget.addWidget(self.interface) - def setup_navigation(self): - """设置导航栏""" self.addSubInterface( self.interface, FluentIcon.PALETTE, - "界面名称", - position=NavigationItemPosition.TOP + "界面名称" ) ``` -### 4.2 界面组织规范 +### 5.2 界面组织规范 -- 每个功能模块创建独立的 `QWidget` 子类作为界面 +- 每个功能模块创建独立的 `QWidget` 子类 - 界面类使用 `setObjectName()` 设置唯一标识 -- 将界面添加到 `stackedWidget` 中 -- 使用 `qrouter.setDefaultRouteKey()` 设置默认路由 ```python class ColorExtractInterface(QWidget): - """色彩提取界面""" - def __init__(self, parent=None): super().__init__(parent) + self.setObjectName('colorExtractInterface') self.setup_ui() self.setup_connections() - - def setup_ui(self): - """设置界面布局""" - layout = QVBoxLayout(self) - # 添加控件... - - def setup_connections(self): - """设置信号连接""" - # 连接信号... ``` -### 4.3 信号与槽 +### 5.3 信号与槽 -- 使用 `Signal` 定义信号 - 信号连接应在初始化时完成 - 槽函数命名使用 `on_` 前缀 ```python -# 信号定义 -class ImageCanvas(QWidget): - color_picked = Signal(int, tuple) -``` # 信号连接 self.image_canvas.color_picked.connect(self.on_color_picked) # 槽函数实现 def on_color_picked(self, index, rgb): """颜色提取回调""" - color_info = get_color_info(*rgb) - self.color_card_panel.update_color(index, color_info) + pass ``` -### 4.4 自定义控件规范 +### 5.4 自定义控件规范 - 继承自合适的 QWidget 子类 - 重写 `paintEvent` 实现自定义绘制 -- 重写鼠标事件实现交互 ```python class ColorPicker(QWidget): """可拖动的圆形取色点""" - def __init__(self, index, parent=None): - super().__init__(parent) - self.index = index - self.radius = 12 - self.setFixedSize(self.radius * 2, self.radius * 2) - def paintEvent(self, event): - """绘制取色点""" painter = QPainter(self) painter.setRenderHint(QPainter.RenderHint.Antialiasing) # 绘制代码... - - def mousePressEvent(self, event): - """鼠标按下事件""" - if event.button() == Qt.MouseButton.LeftButton: - self._dragging = True - event.accept() ``` -### 4.5 样式设置规范 +### 5.5 样式设置规范 -- 使用 `setTheme()` 设置全局主题,避免手动设置样式表 -- 使用 `setThemeColor()` 设置主题色,保持界面风格统一 -- 特殊控件需要自定义样式时,使用 Fluent 风格的设计规范 -- 避免使用 QSS 样式表,优先使用 Fluent 组件 +- 使用 `setTheme()` 设置全局主题 +- **禁止使用硬编码颜色值** +- 使用 `isDarkTheme()` 检测当前主题 ```python -# 设置全局主题 -setTheme(Theme.AUTO) -setThemeColor('#0078d4') +from qfluentwidgets import isDarkTheme +from PySide6.QtGui import QColor -# 特殊控件样式(仅在必要时使用) -self.setStyleSheet("background-color: #2a2a2a; border-radius: 8px;") +def get_text_color(): + """获取主题文本颜色""" + return QColor(255, 255, 255) if isDarkTheme() else QColor(40, 40, 40) ``` -### 4.6 右键菜单规范 +### 5.6 右键菜单规范 - 使用 `RoundMenu` 创建 Fluent 风格的右键菜单 -- 使用 `Action` 创建菜单项,支持 Fluent 图标 -- 菜单标题使用空字符串 `""` 而非父组件 -- 右键菜单仅在适当场景下显示(如已加载图片时) +- 使用 `Action` 创建菜单项 ```python from qfluentwidgets import RoundMenu, Action, FluentIcon def contextMenuEvent(self, event): - """右键菜单事件""" - # 只有在满足条件时才显示右键菜单 - if not self.should_show_menu(): - return - - # 创建菜单,标题参数为空字符串 menu = RoundMenu("") - - # 添加带图标的菜单项 change_action = Action(FluentIcon.PHOTO, "更换图片") - change_action.triggered.connect(self.change_image_requested.emit) menu.addAction(change_action) - - clear_action = Action(FluentIcon.DELETE, "清空图片") - clear_action.triggered.connect(self.clear_image_requested.emit) - menu.addAction(clear_action) - - # 在鼠标位置显示菜单 menu.exec(event.globalPos()) ``` --- -## 5. 颜色处理规范 +## 6. 颜色处理规范 -### 5.1 颜色空间转换 +### 6.1 颜色空间转换 -- 所有颜色转换函数放在 `color_utils.py` -- 使用标准算法进行颜色空间转换 -- 返回结果应包含完整的颜色信息 +- 所有颜色转换函数放在 `core/color.py` +- 返回结果包含完整的颜色信息 ```python def get_color_info(r, g, b): """获取颜色的完整信息""" - h, s, b_val = rgb_to_hsb(r, g, b) - l, a, b_lab = rgb_to_lab(r, g, b) - return { 'rgb': (r, g, b), - 'hsb': (round(h), round(s), round(b_val)), - 'lab': (round(l), round(a), round(b_lab)) + 'hsb': (h, s, v), + 'lab': (l, a, b), + 'hex': rgb_to_hex(r, g, b) } ``` -### 5.2 颜色值显示规范 +### 6.2 明度计算规范 -- HSB 值:H 显示为度(°),S/B 显示为百分比(%) -- LAB 值:直接显示数值 -- RGB 值:用于颜色块显示 +使用 Rec. 709 标准计算亮度值,包含 sRGB Gamma 校正。 + +```python +def get_luminance(r: int, g: int, b: int) -> int: + """计算像素的明度值 (0-255) + + 使用 Rec. 709 标准计算亮度值,包含 sRGB Gamma 校正 + 这是 Lightroom、Photoshop 等专业软件使用的标准方法 + """ + # 步骤1: 归一化到 0-1 范围 + r_norm, g_norm, b_norm = r / 255.0, g / 255.0, b / 255.0 + + # 步骤2: sRGB Gamma 解码(转换到线性空间) + def srgb_to_linear(c): + if c <= 0.04045: + return c / 12.92 + else: + return ((c + 0.055) / 1.055) ** 2.4 + + r_linear = srgb_to_linear(r_norm) + g_linear = srgb_to_linear(g_norm) + b_linear = srgb_to_linear(b_norm) + + # 步骤3: 在线性空间应用 Rec. 709 权重 + luminance_linear = 0.2126 * r_linear + 0.7152 * g_linear + 0.0722 * b_linear + + # 步骤4-5: 编码回 sRGB 空间并转换到 0-255 + # ... +``` + +### 6.3 Zone 分区规范 + +将 0-255 的明度值分为9个区域: + +| Zone | 明度范围 | 描述 | +|:---:|:---:|:---:| +| Zone 0 | 0-28 | 极暗 | +| Zone 1 | 28-56 | 暗 | +| Zone 2 | 56-85 | 中暗 | +| Zone 3 | 85-113 | 次中暗 | +| Zone 4 | 113-141 | 中灰 | +| Zone 5 | 141-170 | 次中亮 | +| Zone 6 | 170-198 | 中亮 | +| Zone 7 | 198-227 | 亮 | +| Zone 8 | 227-255 | 极亮 | --- -## 6. 图片处理规范 +## 7. 图片处理规范 -### 6.1 图片加载规范 +### 7.1 图片加载规范 - 保留原始高分辨率图片 -- 使用 `QPixmap` 用于显示 -- 使用 `QImage` 用于像素读取 +- 使用 `QPixmap` 用于显示,`QImage` 用于像素读取 +- 使用多线程异步加载大图片 + +### 7.2 坐标映射规范 + +**核心原则:** 采样点位置使用**图片相对坐标**(归一化坐标 0.0-1.0)存储。 + +| 坐标类型 | 说明 | 范围 | +|:---:|:---|:---:| +| 相对坐标 | 相对于图片的归一化坐标 | 0.0-1.0 | +| 画布坐标 | 相对于画布控件的像素坐标 | 像素值 | ```python -def set_image(self, image_path): - """加载并显示图片""" - # 加载原始高分辨率图片 - self._original_pixmap = QPixmap(image_path) - self._image = QImage(image_path) +# 相对坐标 → 画布坐标 +canvas_x = disp_x + rel_x * disp_w + +# 画布坐标 → 相对坐标 +rel_x = (canvas_x - disp_x) / disp_w ``` -### 6.2 坐标映射规范 +### 7.3 性能优化规范 + +**基本策略:** +- 使用 `QThread` 在子线程读取图片文件 +- 使用 `QTimer.singleShot()` 延迟执行耗时操作 +- 直方图计算使用采样优化 -- 显示坐标 → 原始图片坐标的映射 -- 使用比例计算确保精度 -- 边界检查防止越界 +**UI性能优化:** +- 批量更新UI时使用 `setUpdatesEnabled(False/True)` 包裹更新操作 +- 避免在循环中频繁更新UI,先收集数据再批量更新 +- 使用 `try-finally` 确保 `setUpdatesEnabled(True)` 一定会执行 ```python -# 将画布坐标转换为图片坐标 -img_x = pos.x() - disp_x -img_y = pos.y() - disp_y - -# 计算在原始图片中的坐标 -scale_x = self._image.width() / disp_w -scale_y = self._image.height() / disp_h -orig_x = int(img_x * scale_x) -orig_y = int(img_y * scale_y) - -# 边界检查 -orig_x = max(0, min(orig_x, self._image.width() - 1)) -orig_y = max(0, min(orig_y, self._image.height() - 1)) +# 批量更新UI示例 +def update_table_data(self): + self.table.setUpdatesEnabled(False) + try: + for row in range(self.table.rowCount()): + self.table.setItem(row, 0, item) + finally: + self.table.setUpdatesEnabled(True) ``` +**数据结构优化:** +- 将列表转换为集合,将查找时间复杂度从 O(n) 降到 O(1) +- 缓存计算结果,避免重复计算和I/O操作 +- 缓存应在数据变化时自动失效,确保数据一致性 + --- -## 7. 界面布局规范 +## 8. 界面布局规范 -### 7.1 布局原则 +### 8.1 布局原则 - 使用布局管理器(QVBoxLayout, QHBoxLayout) - 避免使用固定尺寸,优先使用 size policy -- 合理使用 stretch 分配空间 - -### 7.2 分割器使用 -- 使用 QSplitter 实现可调节区域 -- 设置合理的初始分割比例 -- 设置最小尺寸限制 - -```python -splitter = QSplitter(Qt.Orientation.Vertical) -splitter.addWidget(self.image_canvas) -splitter.addWidget(self.color_card_panel) -splitter.setSizes([500, 200]) -``` - -### 7.3 控件尺寸规范 +### 8.2 控件尺寸参考 | 控件 | 推荐尺寸 | 说明 | |:---:|:---:|:---:| -| 主窗口 | 1200×800 | 默认尺寸,可调整 | -| 主窗口最小 | 1000×700 | 保证内容完整显示 | -| 色卡面板最大高度 | 280px | 防止占用过多空间 | +| 主窗口 | 940×660 | 默认尺寸 | +| 主窗口最小 | 800×550 | 保证内容完整显示 | | 取色点半径 | 12px | 便于拖动操作 | -| 颜色块高度 | 80px | 清晰展示颜色 | --- -## 8. 交互设计规范 +## 9. 交互设计规范 -### 8.1 取色点交互 +### 9.1 取色点交互 - 鼠标悬停:显示手型光标 -- 鼠标按下:显示抓取光标,高亮显示 - 拖动时:实时更新颜色值 -- 边界限制:限制在画布范围内 - -### 8.2 图片导入交互 +- 边界限制:限制在图片显示区域内 -- 无图片时:显示提示文字,点击打开文件对话框 -- 有图片时:显示图片和取色点 -- 支持菜单栏和快捷键导入 - -### 8.3 快捷键规范 +### 9.2 快捷键规范 | 快捷键 | 功能 | |:---:|:---:| @@ -437,108 +573,170 @@ splitter.setSizes([500, 200]) --- -## 9. 代码修改规范 +## 10. 版本管理规范 -### 9.1 修改原则 +### 10.1 版本号格式 -- 小步修改,每次专注于一个功能点 -- 保持向后兼容 -- 修改前备份或使用版本控制 -- 修改后测试验证 +- 格式:`主版本.次版本.修订版本` +- 示例:`1.0.0` +- 主版本:重大功能更新 +- 次版本:新增功能 +- 修订版本:bug 修复 -### 9.2 新增功能规范 +### 10.2 提交信息规范 -1. **需求分析**:明确功能需求和实现方案 -2. **设计**:设计接口和交互方式 -3. **实现**:编写代码,遵循本规范 -4. **测试**:验证功能正确性 -5. **文档**:更新 README 和文档字符串 +**格式:** `[类型] 详细描述` -### 9.3 代码清理 +**类型:** +- `新功能`:新增功能或特性 +- `修复`:修复代码中的错误 +- `优化`:性能或体验改进 +- `重构`:代码结构调整 +- `文档`:修改或新增文档 +- `内容调整`:如替换链接、修改文本等 -- 删除未使用的导入 -- 删除未使用的变量和函数 -- 合并重复逻辑 -- 保持代码简洁 +**示例:** +- `[新功能] 新增用户管理功能` +- `[修复] 修复登录功能的验证逻辑错误` +- `[重构] 提取 BaseCanvas 基类,消除重复代码` +- `[文档] 更新 README.md 和开发规范` --- -## 10. 版本管理规范 +## 11. 配置管理规范 -### 10.1 版本号格式 +### 11.1 配置管理模块 -- 格式:`主版本.次版本.修订版本` -- 示例:`1.0.0` -- 主版本:重大功能更新 -- 次版本:新增功能 -- 修订版本:bug 修复 +使用 `core/config.py` 统一管理应用程序状态。 -### 10.2 提交信息规范 +- 配置文件:JSON 格式,存储在用户主目录下的 `.color_card/config.json` +- 使用单例模式获取全局配置管理器实例 + +### 11.2 配置项说明 + +| 配置键 | 类型 | 默认值 | 说明 | +|:---:|:---:|:---:|:---| +| `settings.hex_visible` | bool | true | 是否显示16进制颜色值 | +| `settings.color_modes` | list | ["HSB", "LAB"] | 色卡中显示的色彩模式 | +| `settings.color_sample_count` | int | 5 | 色彩提取采样点数量 | +| `window.width` | int | 940 | 窗口宽度 | +| `window.height` | int | 660 | 窗口高度 | +| `window.is_maximized` | bool | false | 窗口是否最大化 | + +### 11.3 使用示例 + +```python +from core import get_config_manager + +config_manager = get_config_manager() +config = config_manager.load() -- 使用中文描述 -- 格式:`[类型] 描述` -- 类型包括: - - `新功能`:新增功能 - - `修复`:修复 bug - - `优化`:性能优化 - - `重构`:代码重构 - - `文档`:文档更新 - - `样式`:UI 样式调整 +# 获取配置项 +hex_visible = config_manager.get('settings.hex_visible', True) + +# 设置配置项 +config_manager.set('settings.hex_visible', False) +config_manager.save() +``` --- -## 11. 调试规范 +## 12. 调试规范 -### 11.1 日志输出 +### 12.1 日志管理 -- 使用 print 或 logging 输出调试信息 -- 关键操作添加日志 -- 异常捕获并输出错误信息 +**基本原则:** +- 使用 `print()` 语句进行调试(项目当前阶段) +- 关键操作应记录调试信息,便于调试和问题追踪 -### 11.2 常见问题排查 +**说明:** +根据开发规范,本项目**保留 `print()` 调试语句**,原因如下: +1. 项目仍处于开发迭代阶段,后续功能修改需要调试支持 +2. 调试语句有助于快速定位运行时问题 +3. 方便其他开发者理解和调试代码 +4. 开源发布时可根据需要决定是否保留(生产环境建议使用 `logging` 模块) -| 问题 | 排查方法 | -|:---:|:---:| -| 图片无法加载 | 检查文件路径和格式 | -| 取色点无法拖动 | 检查事件处理和坐标计算 | -| 颜色值不正确 | 检查坐标映射和颜色转换 | -| 界面显示异常 | 检查样式表和布局设置 | +### 12.2 AI辅助调试 + +**日志查看流程:** +1. 应用程序启动失败时,查看控制台输出定位问题 +2. 功能执行出现异常时,分析错误信息 +3. 结合代码上下文,理解错误原因 +4. 根据日志信息制定修复方案 --- -## 12. 扩展开发建议 +## 13. 重构规范 + +### 13.1 重构原则 -### 12.1 潜在功能扩展 +1. **保持功能不变**:重构过程中不改变现有功能 +2. **渐进式重构**:分阶段进行,每阶段可独立验证 +3. **先整理后重构**:先规范现有代码,再提取基类,最后重组目录 +4. **代码清理同步**:修改代码时同步清理相关重复代码 +### 13.2 基类提取规范 + +**步骤:** +1. 分析相似组件的公共逻辑 +2. 设计基类接口(抽象方法) +3. 迁移第一个子类,验证基类设计 +4. 迁移其他子类 +5. 全面测试验证 + +**示例:** +```python +# 基类定义 +class BaseCanvas(QWidget): + def _draw_overlay(self, painter: QPainter): + """绘制叠加内容(子类必须实现)""" + raise NotImplementedError("子类必须实现 _draw_overlay 方法") + +# 子类实现 +class ImageCanvas(BaseCanvas): + def _draw_overlay(self, painter: QPainter): + """绘制取色点""" + # 具体实现... +``` + +### 13.3 模块合并规范 + +**合并原则:** +- 将紧密相关的类合并到同一文件 +- 避免过度拆分,提高代码可维护性 +- 保持模块化,但减少文件数量 + +**示例:** +- `cards.py`:合并 ColorCard、LuminanceCard 及相关基类 +- `histograms.py`:合并 LuminanceHistogramWidget、RGBHistogramWidget 及基类 +- `canvases.py`:合并 BaseCanvas、ImageCanvas、LuminanceCanvas + +--- + +## 14. 附录 + +### 14.1 扩展开发建议 + +**潜在功能扩展:** - 导出颜色方案(JSON、CSS、ASE 等格式) - 历史记录功能 -- 配色规则检查(对比度、色相等) -- 更多颜色空间支持(CMYK、Pantone 等) +- 配色规则检查 - 图片批量处理 -### 12.2 代码扩展原则 - +**代码扩展原则:** - 保持组件化设计 - 新功能应放在独立模块 - 使用信号槽进行组件通信 -- 保持向后兼容 - ---- - -## 13. 总结 -本规范基于 Color Extractor 项目的现有代码结构制定,旨在确保代码的一致性、可维护性和可扩展性。所有开发者应遵循本规范进行开发。 +### 14.2 版本历史 -规范将根据项目发展进行更新,以适应新的功能需求和技术变化。 +| 版本 | 日期 | 变更内容 | +|:---:|:---:|:---| +| 3.2 | 2026-02-05 | 重构项目结构,提取公共基类(BaseCanvas、BaseCard、BaseHistogram),扁平化目录结构,更新文档 | +| 2.1 | 2026-02-04 | 借鉴BetterGI 星轨开发规范,新增导入规范、代码清理原则、异常处理规范、性能优化建议、调试规范 | +| 2.0 | 2026-02-04 | 重构文档结构,精简冗余内容,优化版本号体系 | +| 1.0 | 2026-02-03 | 初始版本,建立基础开发规范 | --- -## 版本历史 - -| 版本 | 日期 | 变更内容 | -|:---:|:---:|:---:| -| 2.3 | 2026-02-03 | 添加 zoom_viewer 组件规范,支持拖动时放大显示 | -| 2.2 | 2026-02-03 | 添加右键菜单规范(RoundMenu、Action 使用规范) | -| 2.1 | 2026-02-03 | 将 PyQt6 替换为 PySide6 | -| 2.0 | 2026-02-03 | 更新为基于 PySide6-Fluent-Widgets 的开发规范 | -| 1.0 | 2026-02-03 | 初始版本,基于项目现有代码结构制定 | +**规范维护:** 本规范将根据项目发展进行更新,以适应新的功能需求和技术变化。