├── .gitattributes ├── .gitignore ├── Hare.exe ├── Hare.int ├── LICENSE ├── README.md ├── ffmpeg.exe ├── md ├── blueaka.png ├── gakumas.png ├── gakumas2.png ├── hare_momotalk.png └── intro.png ├── resources ├── favicon.ico ├── qdark.qss └── splash.png └── script ├── env_checker.py ├── inpaint ├── fsr_parallel.py └── lama.py ├── inpaint_mask.py ├── inpaint_text.py ├── inpaint_video.py └── main_ui.py /.gitattributes: -------------------------------------------------------------------------------- 1 | * linguist-vendored 2 | *.py linguist-vendored=false 3 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | /__*__ 2 | /history 3 | /output 4 | /tmp 5 | /venv 6 | /*/Lib 7 | /*/Scripts 8 | /*/share 9 | /*/*/build 10 | /*/*/dist 11 | /*.zip 12 | /**/*/__pycache__/ 13 | /*.zip 14 | /*.egg 15 | .vscode 16 | 17 | /test 18 | /site-packages 19 | /runtime 20 | *.pt 21 | *.onnx 22 | config.json -------------------------------------------------------------------------------- /Hare.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/Hare.exe -------------------------------------------------------------------------------- /Hare.int: -------------------------------------------------------------------------------- 1 | import sys, os 2 | 3 | os.chdir(os.path.dirname(__file__)) 4 | sys.path.append(os.path.abspath("script")) 5 | 6 | # 中文路径问题 添加 platforms 到环境变量 7 | import PyQt5 8 | dirname = os.path.dirname(PyQt5.__file__) 9 | plugin_path = os.path.join(dirname, "Qt5", "plugins") 10 | os.environ["QT_QPA_PLATFORM_PLUGIN_PATH"] = plugin_path 11 | 12 | import env_checker 13 | if env_checker.main(): 14 | import main_ui 15 | main_ui.main() -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | GNU GENERAL PUBLIC LICENSE 2 | Version 3, 29 June 2007 3 | 4 | Copyright (C) 2007 Free Software Foundation, Inc. 5 | Everyone is permitted to copy and distribute verbatim copies 6 | of this license document, but changing it is not allowed. 7 | 8 | Preamble 9 | 10 | The GNU General Public License is a free, copyleft license for 11 | software and other kinds of works. 12 | 13 | The licenses for most software and other practical works are designed 14 | to take away your freedom to share and change the works. By contrast, 15 | the GNU General Public License is intended to guarantee your freedom to 16 | share and change all versions of a program--to make sure it remains free 17 | software for all its users. We, the Free Software Foundation, use the 18 | GNU General Public License for most of our software; it applies also to 19 | any other work released this way by its authors. You can apply it to 20 | your programs, too. 21 | 22 | When we speak of free software, we are referring to freedom, not 23 | price. Our General Public Licenses are designed to make sure that you 24 | have the freedom to distribute copies of free software (and charge for 25 | them if you wish), that you receive source code or can get it if you 26 | want it, that you can change the software or use pieces of it in new 27 | free programs, and that you know you can do these things. 28 | 29 | To protect your rights, we need to prevent others from denying you 30 | these rights or asking you to surrender the rights. Therefore, you have 31 | certain responsibilities if you distribute copies of the software, or if 32 | you modify it: responsibilities to respect the freedom of others. 33 | 34 | For example, if you distribute copies of such a program, whether 35 | gratis or for a fee, you must pass on to the recipients the same 36 | freedoms that you received. You must make sure that they, too, receive 37 | or can get the source code. And you must show them these terms so they 38 | know their rights. 39 | 40 | Developers that use the GNU GPL protect your rights with two steps: 41 | (1) assert copyright on the software, and (2) offer you this License 42 | giving you legal permission to copy, distribute and/or modify it. 43 | 44 | For the developers' and authors' protection, the GPL clearly explains 45 | that there is no warranty for this free software. For both users' and 46 | authors' sake, the GPL requires that modified versions be marked as 47 | changed, so that their problems will not be attributed erroneously to 48 | authors of previous versions. 49 | 50 | Some devices are designed to deny users access to install or run 51 | modified versions of the software inside them, although the manufacturer 52 | can do so. This is fundamentally incompatible with the aim of 53 | protecting users' freedom to change the software. The systematic 54 | pattern of such abuse occurs in the area of products for individuals to 55 | use, which is precisely where it is most unacceptable. Therefore, we 56 | have designed this version of the GPL to prohibit the practice for those 57 | products. If such problems arise substantially in other domains, we 58 | stand ready to extend this provision to those domains in future versions 59 | of the GPL, as needed to protect the freedom of users. 60 | 61 | Finally, every program is threatened constantly by software patents. 62 | States should not allow patents to restrict development and use of 63 | software on general-purpose computers, but in those that do, we wish to 64 | avoid the special danger that patents applied to a free program could 65 | make it effectively proprietary. To prevent this, the GPL assures that 66 | patents cannot be used to render the program non-free. 67 | 68 | The precise terms and conditions for copying, distribution and 69 | modification follow. 70 | 71 | TERMS AND CONDITIONS 72 | 73 | 0. Definitions. 74 | 75 | "This License" refers to version 3 of the GNU General Public License. 76 | 77 | "Copyright" also means copyright-like laws that apply to other kinds of 78 | works, such as semiconductor masks. 79 | 80 | "The Program" refers to any copyrightable work licensed under this 81 | License. Each licensee is addressed as "you". "Licensees" and 82 | "recipients" may be individuals or organizations. 83 | 84 | To "modify" a work means to copy from or adapt all or part of the work 85 | in a fashion requiring copyright permission, other than the making of an 86 | exact copy. The resulting work is called a "modified version" of the 87 | earlier work or a work "based on" the earlier work. 88 | 89 | A "covered work" means either the unmodified Program or a work based 90 | on the Program. 91 | 92 | To "propagate" a work means to do anything with it that, without 93 | permission, would make you directly or secondarily liable for 94 | infringement under applicable copyright law, except executing it on a 95 | computer or modifying a private copy. Propagation includes copying, 96 | distribution (with or without modification), making available to the 97 | public, and in some countries other activities as well. 98 | 99 | To "convey" a work means any kind of propagation that enables other 100 | parties to make or receive copies. Mere interaction with a user through 101 | a computer network, with no transfer of a copy, is not conveying. 102 | 103 | An interactive user interface displays "Appropriate Legal Notices" 104 | to the extent that it includes a convenient and prominently visible 105 | feature that (1) displays an appropriate copyright notice, and (2) 106 | tells the user that there is no warranty for the work (except to the 107 | extent that warranties are provided), that licensees may convey the 108 | work under this License, and how to view a copy of this License. If 109 | the interface presents a list of user commands or options, such as a 110 | menu, a prominent item in the list meets this criterion. 111 | 112 | 1. Source Code. 113 | 114 | The "source code" for a work means the preferred form of the work 115 | for making modifications to it. "Object code" means any non-source 116 | form of a work. 117 | 118 | A "Standard Interface" means an interface that either is an official 119 | standard defined by a recognized standards body, or, in the case of 120 | interfaces specified for a particular programming language, one that 121 | is widely used among developers working in that language. 122 | 123 | The "System Libraries" of an executable work include anything, other 124 | than the work as a whole, that (a) is included in the normal form of 125 | packaging a Major Component, but which is not part of that Major 126 | Component, and (b) serves only to enable use of the work with that 127 | Major Component, or to implement a Standard Interface for which an 128 | implementation is available to the public in source code form. A 129 | "Major Component", in this context, means a major essential component 130 | (kernel, window system, and so on) of the specific operating system 131 | (if any) on which the executable work runs, or a compiler used to 132 | produce the work, or an object code interpreter used to run it. 133 | 134 | The "Corresponding Source" for a work in object code form means all 135 | the source code needed to generate, install, and (for an executable 136 | work) run the object code and to modify the work, including scripts to 137 | control those activities. However, it does not include the work's 138 | System Libraries, or general-purpose tools or generally available free 139 | programs which are used unmodified in performing those activities but 140 | which are not part of the work. For example, Corresponding Source 141 | includes interface definition files associated with source files for 142 | the work, and the source code for shared libraries and dynamically 143 | linked subprograms that the work is specifically designed to require, 144 | such as by intimate data communication or control flow between those 145 | subprograms and other parts of the work. 146 | 147 | The Corresponding Source need not include anything that users 148 | can regenerate automatically from other parts of the Corresponding 149 | Source. 150 | 151 | The Corresponding Source for a work in source code form is that 152 | same work. 153 | 154 | 2. Basic Permissions. 155 | 156 | All rights granted under this License are granted for the term of 157 | copyright on the Program, and are irrevocable provided the stated 158 | conditions are met. This License explicitly affirms your unlimited 159 | permission to run the unmodified Program. The output from running a 160 | covered work is covered by this License only if the output, given its 161 | content, constitutes a covered work. This License acknowledges your 162 | rights of fair use or other equivalent, as provided by copyright law. 163 | 164 | You may make, run and propagate covered works that you do not 165 | convey, without conditions so long as your license otherwise remains 166 | in force. You may convey covered works to others for the sole purpose 167 | of having them make modifications exclusively for you, or provide you 168 | with facilities for running those works, provided that you comply with 169 | the terms of this License in conveying all material for which you do 170 | not control copyright. Those thus making or running the covered works 171 | for you must do so exclusively on your behalf, under your direction 172 | and control, on terms that prohibit them from making any copies of 173 | your copyrighted material outside their relationship with you. 174 | 175 | Conveying under any other circumstances is permitted solely under 176 | the conditions stated below. Sublicensing is not allowed; section 10 177 | makes it unnecessary. 178 | 179 | 3. Protecting Users' Legal Rights From Anti-Circumvention Law. 180 | 181 | No covered work shall be deemed part of an effective technological 182 | measure under any applicable law fulfilling obligations under article 183 | 11 of the WIPO copyright treaty adopted on 20 December 1996, or 184 | similar laws prohibiting or restricting circumvention of such 185 | measures. 186 | 187 | When you convey a covered work, you waive any legal power to forbid 188 | circumvention of technological measures to the extent such circumvention 189 | is effected by exercising rights under this License with respect to 190 | the covered work, and you disclaim any intention to limit operation or 191 | modification of the work as a means of enforcing, against the work's 192 | users, your or third parties' legal rights to forbid circumvention of 193 | technological measures. 194 | 195 | 4. Conveying Verbatim Copies. 196 | 197 | You may convey verbatim copies of the Program's source code as you 198 | receive it, in any medium, provided that you conspicuously and 199 | appropriately publish on each copy an appropriate copyright notice; 200 | keep intact all notices stating that this License and any 201 | non-permissive terms added in accord with section 7 apply to the code; 202 | keep intact all notices of the absence of any warranty; and give all 203 | recipients a copy of this License along with the Program. 204 | 205 | You may charge any price or no price for each copy that you convey, 206 | and you may offer support or warranty protection for a fee. 207 | 208 | 5. Conveying Modified Source Versions. 209 | 210 | You may convey a work based on the Program, or the modifications to 211 | produce it from the Program, in the form of source code under the 212 | terms of section 4, provided that you also meet all of these conditions: 213 | 214 | a) The work must carry prominent notices stating that you modified 215 | it, and giving a relevant date. 216 | 217 | b) The work must carry prominent notices stating that it is 218 | released under this License and any conditions added under section 219 | 7. This requirement modifies the requirement in section 4 to 220 | "keep intact all notices". 221 | 222 | c) You must license the entire work, as a whole, under this 223 | License to anyone who comes into possession of a copy. This 224 | License will therefore apply, along with any applicable section 7 225 | additional terms, to the whole of the work, and all its parts, 226 | regardless of how they are packaged. This License gives no 227 | permission to license the work in any other way, but it does not 228 | invalidate such permission if you have separately received it. 229 | 230 | d) If the work has interactive user interfaces, each must display 231 | Appropriate Legal Notices; however, if the Program has interactive 232 | interfaces that do not display Appropriate Legal Notices, your 233 | work need not make them do so. 234 | 235 | A compilation of a covered work with other separate and independent 236 | works, which are not by their nature extensions of the covered work, 237 | and which are not combined with it such as to form a larger program, 238 | in or on a volume of a storage or distribution medium, is called an 239 | "aggregate" if the compilation and its resulting copyright are not 240 | used to limit the access or legal rights of the compilation's users 241 | beyond what the individual works permit. Inclusion of a covered work 242 | in an aggregate does not cause this License to apply to the other 243 | parts of the aggregate. 244 | 245 | 6. Conveying Non-Source Forms. 246 | 247 | You may convey a covered work in object code form under the terms 248 | of sections 4 and 5, provided that you also convey the 249 | machine-readable Corresponding Source under the terms of this License, 250 | in one of these ways: 251 | 252 | a) Convey the object code in, or embodied in, a physical product 253 | (including a physical distribution medium), accompanied by the 254 | Corresponding Source fixed on a durable physical medium 255 | customarily used for software interchange. 256 | 257 | b) Convey the object code in, or embodied in, a physical product 258 | (including a physical distribution medium), accompanied by a 259 | written offer, valid for at least three years and valid for as 260 | long as you offer spare parts or customer support for that product 261 | model, to give anyone who possesses the object code either (1) a 262 | copy of the Corresponding Source for all the software in the 263 | product that is covered by this License, on a durable physical 264 | medium customarily used for software interchange, for a price no 265 | more than your reasonable cost of physically performing this 266 | conveying of source, or (2) access to copy the 267 | Corresponding Source from a network server at no charge. 268 | 269 | c) Convey individual copies of the object code with a copy of the 270 | written offer to provide the Corresponding Source. This 271 | alternative is allowed only occasionally and noncommercially, and 272 | only if you received the object code with such an offer, in accord 273 | with subsection 6b. 274 | 275 | d) Convey the object code by offering access from a designated 276 | place (gratis or for a charge), and offer equivalent access to the 277 | Corresponding Source in the same way through the same place at no 278 | further charge. You need not require recipients to copy the 279 | Corresponding Source along with the object code. If the place to 280 | copy the object code is a network server, the Corresponding Source 281 | may be on a different server (operated by you or a third party) 282 | that supports equivalent copying facilities, provided you maintain 283 | clear directions next to the object code saying where to find the 284 | Corresponding Source. Regardless of what server hosts the 285 | Corresponding Source, you remain obligated to ensure that it is 286 | available for as long as needed to satisfy these requirements. 287 | 288 | e) Convey the object code using peer-to-peer transmission, provided 289 | you inform other peers where the object code and Corresponding 290 | Source of the work are being offered to the general public at no 291 | charge under subsection 6d. 292 | 293 | A separable portion of the object code, whose source code is excluded 294 | from the Corresponding Source as a System Library, need not be 295 | included in conveying the object code work. 296 | 297 | A "User Product" is either (1) a "consumer product", which means any 298 | tangible personal property which is normally used for personal, family, 299 | or household purposes, or (2) anything designed or sold for incorporation 300 | into a dwelling. In determining whether a product is a consumer product, 301 | doubtful cases shall be resolved in favor of coverage. For a particular 302 | product received by a particular user, "normally used" refers to a 303 | typical or common use of that class of product, regardless of the status 304 | of the particular user or of the way in which the particular user 305 | actually uses, or expects or is expected to use, the product. A product 306 | is a consumer product regardless of whether the product has substantial 307 | commercial, industrial or non-consumer uses, unless such uses represent 308 | the only significant mode of use of the product. 309 | 310 | "Installation Information" for a User Product means any methods, 311 | procedures, authorization keys, or other information required to install 312 | and execute modified versions of a covered work in that User Product from 313 | a modified version of its Corresponding Source. The information must 314 | suffice to ensure that the continued functioning of the modified object 315 | code is in no case prevented or interfered with solely because 316 | modification has been made. 317 | 318 | If you convey an object code work under this section in, or with, or 319 | specifically for use in, a User Product, and the conveying occurs as 320 | part of a transaction in which the right of possession and use of the 321 | User Product is transferred to the recipient in perpetuity or for a 322 | fixed term (regardless of how the transaction is characterized), the 323 | Corresponding Source conveyed under this section must be accompanied 324 | by the Installation Information. But this requirement does not apply 325 | if neither you nor any third party retains the ability to install 326 | modified object code on the User Product (for example, the work has 327 | been installed in ROM). 328 | 329 | The requirement to provide Installation Information does not include a 330 | requirement to continue to provide support service, warranty, or updates 331 | for a work that has been modified or installed by the recipient, or for 332 | the User Product in which it has been modified or installed. Access to a 333 | network may be denied when the modification itself materially and 334 | adversely affects the operation of the network or violates the rules and 335 | protocols for communication across the network. 336 | 337 | Corresponding Source conveyed, and Installation Information provided, 338 | in accord with this section must be in a format that is publicly 339 | documented (and with an implementation available to the public in 340 | source code form), and must require no special password or key for 341 | unpacking, reading or copying. 342 | 343 | 7. Additional Terms. 344 | 345 | "Additional permissions" are terms that supplement the terms of this 346 | License by making exceptions from one or more of its conditions. 347 | Additional permissions that are applicable to the entire Program shall 348 | be treated as though they were included in this License, to the extent 349 | that they are valid under applicable law. If additional permissions 350 | apply only to part of the Program, that part may be used separately 351 | under those permissions, but the entire Program remains governed by 352 | this License without regard to the additional permissions. 353 | 354 | When you convey a copy of a covered work, you may at your option 355 | remove any additional permissions from that copy, or from any part of 356 | it. (Additional permissions may be written to require their own 357 | removal in certain cases when you modify the work.) You may place 358 | additional permissions on material, added by you to a covered work, 359 | for which you have or can give appropriate copyright permission. 360 | 361 | Notwithstanding any other provision of this License, for material you 362 | add to a covered work, you may (if authorized by the copyright holders of 363 | that material) supplement the terms of this License with terms: 364 | 365 | a) Disclaiming warranty or limiting liability differently from the 366 | terms of sections 15 and 16 of this License; or 367 | 368 | b) Requiring preservation of specified reasonable legal notices or 369 | author attributions in that material or in the Appropriate Legal 370 | Notices displayed by works containing it; or 371 | 372 | c) Prohibiting misrepresentation of the origin of that material, or 373 | requiring that modified versions of such material be marked in 374 | reasonable ways as different from the original version; or 375 | 376 | d) Limiting the use for publicity purposes of names of licensors or 377 | authors of the material; or 378 | 379 | e) Declining to grant rights under trademark law for use of some 380 | trade names, trademarks, or service marks; or 381 | 382 | f) Requiring indemnification of licensors and authors of that 383 | material by anyone who conveys the material (or modified versions of 384 | it) with contractual assumptions of liability to the recipient, for 385 | any liability that these contractual assumptions directly impose on 386 | those licensors and authors. 387 | 388 | All other non-permissive additional terms are considered "further 389 | restrictions" within the meaning of section 10. If the Program as you 390 | received it, or any part of it, contains a notice stating that it is 391 | governed by this License along with a term that is a further 392 | restriction, you may remove that term. If a license document contains 393 | a further restriction but permits relicensing or conveying under this 394 | License, you may add to a covered work material governed by the terms 395 | of that license document, provided that the further restriction does 396 | not survive such relicensing or conveying. 397 | 398 | If you add terms to a covered work in accord with this section, you 399 | must place, in the relevant source files, a statement of the 400 | additional terms that apply to those files, or a notice indicating 401 | where to find the applicable terms. 402 | 403 | Additional terms, permissive or non-permissive, may be stated in the 404 | form of a separately written license, or stated as exceptions; 405 | the above requirements apply either way. 406 | 407 | 8. Termination. 408 | 409 | You may not propagate or modify a covered work except as expressly 410 | provided under this License. Any attempt otherwise to propagate or 411 | modify it is void, and will automatically terminate your rights under 412 | this License (including any patent licenses granted under the third 413 | paragraph of section 11). 414 | 415 | However, if you cease all violation of this License, then your 416 | license from a particular copyright holder is reinstated (a) 417 | provisionally, unless and until the copyright holder explicitly and 418 | finally terminates your license, and (b) permanently, if the copyright 419 | holder fails to notify you of the violation by some reasonable means 420 | prior to 60 days after the cessation. 421 | 422 | Moreover, your license from a particular copyright holder is 423 | reinstated permanently if the copyright holder notifies you of the 424 | violation by some reasonable means, this is the first time you have 425 | received notice of violation of this License (for any work) from that 426 | copyright holder, and you cure the violation prior to 30 days after 427 | your receipt of the notice. 428 | 429 | Termination of your rights under this section does not terminate the 430 | licenses of parties who have received copies or rights from you under 431 | this License. If your rights have been terminated and not permanently 432 | reinstated, you do not qualify to receive new licenses for the same 433 | material under section 10. 434 | 435 | 9. Acceptance Not Required for Having Copies. 436 | 437 | You are not required to accept this License in order to receive or 438 | run a copy of the Program. Ancillary propagation of a covered work 439 | occurring solely as a consequence of using peer-to-peer transmission 440 | to receive a copy likewise does not require acceptance. However, 441 | nothing other than this License grants you permission to propagate or 442 | modify any covered work. These actions infringe copyright if you do 443 | not accept this License. Therefore, by modifying or propagating a 444 | covered work, you indicate your acceptance of this License to do so. 445 | 446 | 10. Automatic Licensing of Downstream Recipients. 447 | 448 | Each time you convey a covered work, the recipient automatically 449 | receives a license from the original licensors, to run, modify and 450 | propagate that work, subject to this License. You are not responsible 451 | for enforcing compliance by third parties with this License. 452 | 453 | An "entity transaction" is a transaction transferring control of an 454 | organization, or substantially all assets of one, or subdividing an 455 | organization, or merging organizations. If propagation of a covered 456 | work results from an entity transaction, each party to that 457 | transaction who receives a copy of the work also receives whatever 458 | licenses to the work the party's predecessor in interest had or could 459 | give under the previous paragraph, plus a right to possession of the 460 | Corresponding Source of the work from the predecessor in interest, if 461 | the predecessor has it or can get it with reasonable efforts. 462 | 463 | You may not impose any further restrictions on the exercise of the 464 | rights granted or affirmed under this License. For example, you may 465 | not impose a license fee, royalty, or other charge for exercise of 466 | rights granted under this License, and you may not initiate litigation 467 | (including a cross-claim or counterclaim in a lawsuit) alleging that 468 | any patent claim is infringed by making, using, selling, offering for 469 | sale, or importing the Program or any portion of it. 470 | 471 | 11. Patents. 472 | 473 | A "contributor" is a copyright holder who authorizes use under this 474 | License of the Program or a work on which the Program is based. The 475 | work thus licensed is called the contributor's "contributor version". 476 | 477 | A contributor's "essential patent claims" are all patent claims 478 | owned or controlled by the contributor, whether already acquired or 479 | hereafter acquired, that would be infringed by some manner, permitted 480 | by this License, of making, using, or selling its contributor version, 481 | but do not include claims that would be infringed only as a 482 | consequence of further modification of the contributor version. For 483 | purposes of this definition, "control" includes the right to grant 484 | patent sublicenses in a manner consistent with the requirements of 485 | this License. 486 | 487 | Each contributor grants you a non-exclusive, worldwide, royalty-free 488 | patent license under the contributor's essential patent claims, to 489 | make, use, sell, offer for sale, import and otherwise run, modify and 490 | propagate the contents of its contributor version. 491 | 492 | In the following three paragraphs, a "patent license" is any express 493 | agreement or commitment, however denominated, not to enforce a patent 494 | (such as an express permission to practice a patent or covenant not to 495 | sue for patent infringement). To "grant" such a patent license to a 496 | party means to make such an agreement or commitment not to enforce a 497 | patent against the party. 498 | 499 | If you convey a covered work, knowingly relying on a patent license, 500 | and the Corresponding Source of the work is not available for anyone 501 | to copy, free of charge and under the terms of this License, through a 502 | publicly available network server or other readily accessible means, 503 | then you must either (1) cause the Corresponding Source to be so 504 | available, or (2) arrange to deprive yourself of the benefit of the 505 | patent license for this particular work, or (3) arrange, in a manner 506 | consistent with the requirements of this License, to extend the patent 507 | license to downstream recipients. "Knowingly relying" means you have 508 | actual knowledge that, but for the patent license, your conveying the 509 | covered work in a country, or your recipient's use of the covered work 510 | in a country, would infringe one or more identifiable patents in that 511 | country that you have reason to believe are valid. 512 | 513 | If, pursuant to or in connection with a single transaction or 514 | arrangement, you convey, or propagate by procuring conveyance of, a 515 | covered work, and grant a patent license to some of the parties 516 | receiving the covered work authorizing them to use, propagate, modify 517 | or convey a specific copy of the covered work, then the patent license 518 | you grant is automatically extended to all recipients of the covered 519 | work and works based on it. 520 | 521 | A patent license is "discriminatory" if it does not include within 522 | the scope of its coverage, prohibits the exercise of, or is 523 | conditioned on the non-exercise of one or more of the rights that are 524 | specifically granted under this License. You may not convey a covered 525 | work if you are a party to an arrangement with a third party that is 526 | in the business of distributing software, under which you make payment 527 | to the third party based on the extent of your activity of conveying 528 | the work, and under which the third party grants, to any of the 529 | parties who would receive the covered work from you, a discriminatory 530 | patent license (a) in connection with copies of the covered work 531 | conveyed by you (or copies made from those copies), or (b) primarily 532 | for and in connection with specific products or compilations that 533 | contain the covered work, unless you entered into that arrangement, 534 | or that patent license was granted, prior to 28 March 2007. 535 | 536 | Nothing in this License shall be construed as excluding or limiting 537 | any implied license or other defenses to infringement that may 538 | otherwise be available to you under applicable patent law. 539 | 540 | 12. No Surrender of Others' Freedom. 541 | 542 | If conditions are imposed on you (whether by court order, agreement or 543 | otherwise) that contradict the conditions of this License, they do not 544 | excuse you from the conditions of this License. If you cannot convey a 545 | covered work so as to satisfy simultaneously your obligations under this 546 | License and any other pertinent obligations, then as a consequence you may 547 | not convey it at all. For example, if you agree to terms that obligate you 548 | to collect a royalty for further conveying from those to whom you convey 549 | the Program, the only way you could satisfy both those terms and this 550 | License would be to refrain entirely from conveying the Program. 551 | 552 | 13. Use with the GNU Affero General Public License. 553 | 554 | Notwithstanding any other provision of this License, you have 555 | permission to link or combine any covered work with a work licensed 556 | under version 3 of the GNU Affero General Public License into a single 557 | combined work, and to convey the resulting work. The terms of this 558 | License will continue to apply to the part which is the covered work, 559 | but the special requirements of the GNU Affero General Public License, 560 | section 13, concerning interaction through a network will apply to the 561 | combination as such. 562 | 563 | 14. Revised Versions of this License. 564 | 565 | The Free Software Foundation may publish revised and/or new versions of 566 | the GNU General Public License from time to time. Such new versions will 567 | be similar in spirit to the present version, but may differ in detail to 568 | address new problems or concerns. 569 | 570 | Each version is given a distinguishing version number. If the 571 | Program specifies that a certain numbered version of the GNU General 572 | Public License "or any later version" applies to it, you have the 573 | option of following the terms and conditions either of that numbered 574 | version or of any later version published by the Free Software 575 | Foundation. If the Program does not specify a version number of the 576 | GNU General Public License, you may choose any version ever published 577 | by the Free Software Foundation. 578 | 579 | If the Program specifies that a proxy can decide which future 580 | versions of the GNU General Public License can be used, that proxy's 581 | public statement of acceptance of a version permanently authorizes you 582 | to choose that version for the Program. 583 | 584 | Later license versions may give you additional or different 585 | permissions. However, no additional obligations are imposed on any 586 | author or copyright holder as a result of your choosing to follow a 587 | later version. 588 | 589 | 15. Disclaimer of Warranty. 590 | 591 | THERE IS NO WARRANTY FOR THE PROGRAM, TO THE EXTENT PERMITTED BY 592 | APPLICABLE LAW. EXCEPT WHEN OTHERWISE STATED IN WRITING THE COPYRIGHT 593 | HOLDERS AND/OR OTHER PARTIES PROVIDE THE PROGRAM "AS IS" WITHOUT WARRANTY 594 | OF ANY KIND, EITHER EXPRESSED OR IMPLIED, INCLUDING, BUT NOT LIMITED TO, 595 | THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR 596 | PURPOSE. THE ENTIRE RISK AS TO THE QUALITY AND PERFORMANCE OF THE PROGRAM 597 | IS WITH YOU. SHOULD THE PROGRAM PROVE DEFECTIVE, YOU ASSUME THE COST OF 598 | ALL NECESSARY SERVICING, REPAIR OR CORRECTION. 599 | 600 | 16. Limitation of Liability. 601 | 602 | IN NO EVENT UNLESS REQUIRED BY APPLICABLE LAW OR AGREED TO IN WRITING 603 | WILL ANY COPYRIGHT HOLDER, OR ANY OTHER PARTY WHO MODIFIES AND/OR CONVEYS 604 | THE PROGRAM AS PERMITTED ABOVE, BE LIABLE TO YOU FOR DAMAGES, INCLUDING ANY 605 | GENERAL, SPECIAL, INCIDENTAL OR CONSEQUENTIAL DAMAGES ARISING OUT OF THE 606 | USE OR INABILITY TO USE THE PROGRAM (INCLUDING BUT NOT LIMITED TO LOSS OF 607 | DATA OR DATA BEING RENDERED INACCURATE OR LOSSES SUSTAINED BY YOU OR THIRD 608 | PARTIES OR A FAILURE OF THE PROGRAM TO OPERATE WITH ANY OTHER PROGRAMS), 609 | EVEN IF SUCH HOLDER OR OTHER PARTY HAS BEEN ADVISED OF THE POSSIBILITY OF 610 | SUCH DAMAGES. 611 | 612 | 17. Interpretation of Sections 15 and 16. 613 | 614 | If the disclaimer of warranty and limitation of liability provided 615 | above cannot be given local legal effect according to their terms, 616 | reviewing courts shall apply local law that most closely approximates 617 | an absolute waiver of all civil liability in connection with the 618 | Program, unless a warranty or assumption of liability accompanies a 619 | copy of the Program in return for a fee. 620 | 621 | END OF TERMS AND CONDITIONS 622 | 623 | How to Apply These Terms to Your New Programs 624 | 625 | If you develop a new program, and you want it to be of the greatest 626 | possible use to the public, the best way to achieve this is to make it 627 | free software which everyone can redistribute and change under these terms. 628 | 629 | To do so, attach the following notices to the program. It is safest 630 | to attach them to the start of each source file to most effectively 631 | state the exclusion of warranty; and each file should have at least 632 | the "copyright" line and a pointer to where the full notice is found. 633 | 634 | 635 | Copyright (C) 636 | 637 | This program is free software: you can redistribute it and/or modify 638 | it under the terms of the GNU General Public License as published by 639 | the Free Software Foundation, either version 3 of the License, or 640 | (at your option) any later version. 641 | 642 | This program is distributed in the hope that it will be useful, 643 | but WITHOUT ANY WARRANTY; without even the implied warranty of 644 | MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 645 | GNU General Public License for more details. 646 | 647 | You should have received a copy of the GNU General Public License 648 | along with this program. If not, see . 649 | 650 | Also add information on how to contact you by electronic and paper mail. 651 | 652 | If the program does terminal interaction, make it output a short 653 | notice like this when it starts in an interactive mode: 654 | 655 | Copyright (C) 656 | This program comes with ABSOLUTELY NO WARRANTY; for details type `show w'. 657 | This is free software, and you are welcome to redistribute it 658 | under certain conditions; type `show c' for details. 659 | 660 | The hypothetical commands `show w' and `show c' should show the appropriate 661 | parts of the General Public License. Of course, your program's commands 662 | might be different; for a GUI interface, you would use an "about box". 663 | 664 | You should also get your employer (if you work as a programmer) or school, 665 | if any, to sign a "copyright disclaimer" for the program, if necessary. 666 | For more information on this, and how to apply and follow the GNU GPL, see 667 | . 668 | 669 | The GNU General Public License does not permit incorporating your program 670 | into proprietary programs. If your program is a subroutine library, you 671 | may consider it more useful to permit linking proprietary applications with 672 | the library. If this is what you want to do, use the GNU Lesser General 673 | Public License instead of this License. But first, please read 674 | . -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |
2 | 3 | ![maven](https://img.shields.io/badge/Python-3.8%2B-blue) 4 | ![maven](https://img.shields.io/badge/OpenCV-4.10.0-yellow) 5 | ![maven](https://img.shields.io/badge/pyQt-5.15.10-red) 6 | 7 | # 视频文字清除工具 8 | 9 | **** 10 | 11 | “今天,晴会保佑你,不管写什么代码,都不会出现漏洞。” —— 小钩晴 [▶️](https://static.kivo.wiki/voices/students/%E5%B0%8F%E9%92%A9%20%E6%99%B4/guF8G61lNHMhqdeztHSHTAMMEmCG1qy1.ogg) 12 | 13 | **** 14 | 15 | 此项目基于 OpenCV 和 pyQt5 开发,用于清除游戏剧情录屏中的字幕,方便汉化 16 | 17 | ## 下载 18 | 19 | [release](https://github.com/U1805/Hare/releases/latest) <- 从这里下载 20 | 21 | 下载 `Hare.zip`,解压压缩包后你应该得到下面的文件结构 22 | 23 | ``` 24 | 📁 Hare 25 | ├─📁 resources 26 | ├─📁 runtime 27 | ├─📁 script 28 | ├─📁 site-packages 29 | ├─⚙️ ffmpeg.exe 30 | ├─🚀 Hare.exe <- 双击运行 31 | └─⚙️ Hare.int 32 | ``` 33 | 34 | [遇到报错?](#安装报错) 35 | 36 | ## 效果 37 | 38 | ![blueaka](./md/blueaka.png) 39 | 40 | ![gukamas](./md/gakumas2.png) 41 | 42 | ## 快速上手 43 | 44 | ![region](./md/intro.png) 45 | 46 | 1. 加载视频文件 47 | - 打开文件:菜单栏的 `文件` -> `选择视频` 48 | - 预览视频:滑动控制栏的进度条预览视频内容 49 | 2. 加载时轴文件 50 | - 打开文件:菜单栏的 `文件` -> `选择字幕` 51 | 3. 创建修复区域 52 | - 点击一个行标题 53 | - 如果修复灰色文字,双击行标题选择灰色 54 | - 在左侧视频输入区域,按住鼠标左键并拖动 55 | - 不同的修复区域红框不会同时显示 56 | - 算法选择 **MASK**,点击 `测试当前帧` 57 | 4. 选择修复算法 58 | - 算法选择 **INPAINT**,点击 `测试当前帧` 59 | 5. 运行修复算法 60 | - 设置完成后,点击 `开始运行` 按钮 61 | - 结果文件在视频同目录,文件名以 output 结尾 62 | 63 | ## 算法选择 64 | 65 | - **MASK**:掩码算法,红色部分是修复算法会处理的区域 66 | 67 | - **INPAINT**:INPAINT 开头为修复算法, 68 | INPAINT_LAMA (GPU 算法,耗时 1.5x) 69 | INPAINT_NS (CPU 算法,耗时 1.5x) 70 | INPAINT_FSR_PARA (CPU 算法,耗时 5x) 71 | 72 | > 优先使用 INPAINT_LAMA 73 | 74 | 80 | 81 | ## 安装报错 82 | 83 | > 报错信息:WARNING: Retrying (Retry(total=4, connect=None, read=None, redirect=None, status=None)) after connection broken by 'SSLError(SSLEOFError(8, 'EOF occurred in violation of protocol (_ssl.c:1131)'))': xxx 84 | > 85 | > 解决方法:关闭 VPN 86 | 87 | ## 调试 88 | 89 | 1. 下载代码 90 | `git clone https://github.com/U1805/Hare.git --depth=1` 91 | 2. conda 创建 Python3.8 环境 92 | `conda create --name hare python=3.8` 93 | 3. 运行 `python ./Hare.int` 94 | 95 | ## TODO 96 | 97 | - [ ] 更好的掩码算法 98 | - [ ] 检测到半透明/渐隐的字 99 | - [ ] 更好的修复算法 100 | - [x] INPAINT_FSR_FAST 101 | - [x] INPAINT_FSR_BEST 102 | - [x] INPAINT_FSR_PARA (并发的FAST,速度约快一倍) 103 | - [x] INPAINT_LAMA 104 | 105 | ## License 106 | 107 | [GNU license](./LICENSE) 108 | 109 | ## 感谢 110 | 111 | - [FFmpeg](http://ffmpeg.org/) - A complete, cross-platform solution to record, convert and stream audio and video. 112 | - [advimman/lama](https://github.com/advimman/lama) - 🦙 LaMa Image Inpainting, Resolution-robust Large Mask Inpainting with Fourier Convolutions, WACV 2022 113 | - [skywind3000/PyStand](https://github.com/skywind3000/PyStand) - 🚀 Python Standalone Deploy Environment !! -------------------------------------------------------------------------------- /ffmpeg.exe: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/ffmpeg.exe -------------------------------------------------------------------------------- /md/blueaka.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/md/blueaka.png -------------------------------------------------------------------------------- /md/gakumas.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/md/gakumas.png -------------------------------------------------------------------------------- /md/gakumas2.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/md/gakumas2.png -------------------------------------------------------------------------------- /md/hare_momotalk.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/md/hare_momotalk.png -------------------------------------------------------------------------------- /md/intro.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/md/intro.png -------------------------------------------------------------------------------- /resources/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/resources/favicon.ico -------------------------------------------------------------------------------- /resources/qdark.qss: -------------------------------------------------------------------------------- 1 | /* 2 | The MIT License 3 | 4 | Copyright (c) 2013-2018 Colin Duquesnoy https://github.com/ColinDuquesnoy/QDarkStyleSheet/blob/master/LICENSE.md 5 | 6 | Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated 7 | documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to 8 | use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to 9 | whom the Software is furnished to do so, subject to the following conditions: 10 | 11 | The above copyright notice and this permission notice shall be included in all copies or substantial portions of the 12 | Software. 13 | 14 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT 15 | LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND 16 | NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, 17 | DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT 18 | OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. 19 | */ 20 | 21 | 22 | QToolTip { 23 | border: 1px solid #76797C; 24 | background-color: #5A7566; 25 | color: white; 26 | padding: 0px; /*remove padding, for fix combobox tooltip.*/ 27 | opacity: 200; 28 | } 29 | 30 | QWidget { 31 | color: #eff0f1; 32 | background-color: #31363b; 33 | selection-background-color: #3daee9; 34 | selection-color: #eff0f1; 35 | background-clip: border; 36 | border-image: none; 37 | border: 0px transparent black; 38 | outline: 0; 39 | } 40 | 41 | QWidget:item:hover { 42 | background-color: #18465d; 43 | color: #eff0f1; 44 | } 45 | 46 | QWidget:item:selected { 47 | background-color: #18465d; 48 | } 49 | 50 | QGroupBox::indicator { 51 | width: 18px; 52 | height: 18px; 53 | } 54 | 55 | QGroupBox::indicator { 56 | margin-left: 2px; 57 | } 58 | 59 | QGroupBox::indicator:unchecked { 60 | image: url(:/qss_icons/rc/checkbox_unchecked.png); 61 | } 62 | 63 | QGroupBox::indicator:unchecked:hover, 64 | QGroupBox::indicator:unchecked:focus, 65 | QGroupBox::indicator:unchecked:pressed { 66 | border: none; 67 | image: url(:/qss_icons/rc/checkbox_unchecked_focus.png); 68 | } 69 | 70 | QGroupBox::indicator:checked { 71 | image: url(:/qss_icons/rc/checkbox_checked.png); 72 | } 73 | 74 | QGroupBox::indicator:checked:hover, 75 | QGroupBox::indicator:checked:focus, 76 | QGroupBox::indicator:checked:pressed { 77 | border: none; 78 | image: url(:/qss_icons/rc/checkbox_checked_focus.png); 79 | } 80 | 81 | QCheckBox::indicator:indeterminate { 82 | image: url(:/qss_icons/rc/checkbox_indeterminate.png); 83 | } 84 | 85 | QCheckBox::indicator:indeterminate:focus, 86 | QCheckBox::indicator:indeterminate:hover, 87 | QCheckBox::indicator:indeterminate:pressed { 88 | image: url(:/qss_icons/rc/checkbox_indeterminate_focus.png); 89 | } 90 | 91 | QCheckBox::indicator:checked:disabled, 92 | QGroupBox::indicator:checked:disabled { 93 | image: url(:/qss_icons/rc/checkbox_checked_disabled.png); 94 | } 95 | 96 | QCheckBox::indicator:unchecked:disabled, 97 | QGroupBox::indicator:unchecked:disabled { 98 | image: url(:/qss_icons/rc/checkbox_unchecked_disabled.png); 99 | } 100 | 101 | QRadioButton { 102 | spacing: 5px; 103 | outline: none; 104 | color: #eff0f1; 105 | margin-bottom: 2px; 106 | } 107 | 108 | QRadioButton:disabled { 109 | color: #76797C; 110 | } 111 | 112 | QRadioButton::indicator { 113 | width: 21px; 114 | height: 21px; 115 | } 116 | 117 | QRadioButton::indicator:unchecked { 118 | image: url(:/qss_icons/rc/radio_unchecked.png); 119 | } 120 | 121 | QRadioButton::indicator:unchecked:hover, 122 | QRadioButton::indicator:unchecked:focus, 123 | QRadioButton::indicator:unchecked:pressed { 124 | border: none; 125 | outline: none; 126 | image: url(:/qss_icons/rc/radio_unchecked_focus.png); 127 | } 128 | 129 | QRadioButton::indicator:checked { 130 | border: none; 131 | outline: none; 132 | image: url(:/qss_icons/rc/radio_checked.png); 133 | } 134 | 135 | QRadioButton::indicator:checked:hover, 136 | QRadioButton::indicator:checked:focus, 137 | QRadioButton::indicator:checked:pressed { 138 | border: none; 139 | outline: none; 140 | image: url(:/qss_icons/rc/radio_checked_focus.png); 141 | } 142 | 143 | QRadioButton::indicator:checked:disabled { 144 | outline: none; 145 | image: url(:/qss_icons/rc/radio_checked_disabled.png); 146 | } 147 | 148 | QRadioButton::indicator:unchecked:disabled { 149 | image: url(:/qss_icons/rc/radio_unchecked_disabled.png); 150 | } 151 | 152 | QMenuBar { 153 | background-color: #31363b; 154 | color: #eff0f1; 155 | } 156 | 157 | QMenuBar::item { 158 | background: transparent; 159 | } 160 | 161 | QMenuBar::item:selected { 162 | background: transparent; 163 | border: 1px solid #76797C; 164 | } 165 | 166 | QMenuBar::item:pressed { 167 | border: 1px solid #76797C; 168 | background-color: #3daee9; 169 | color: #eff0f1; 170 | margin-bottom: -1px; 171 | padding-bottom: 1px; 172 | } 173 | 174 | QMenu { 175 | border: 1px solid #76797C; 176 | color: #eff0f1; 177 | margin: 2px; 178 | } 179 | 180 | QMenu::icon { 181 | margin: 5px; 182 | } 183 | 184 | QMenu::item { 185 | padding: 5px 30px 5px 30px; 186 | border: 1px solid transparent; 187 | /* reserve space for selection border */ 188 | } 189 | 190 | QMenu::item:selected { 191 | color: #eff0f1; 192 | } 193 | 194 | QMenu::separator { 195 | height: 2px; 196 | background: lightblue; 197 | margin-left: 10px; 198 | margin-right: 5px; 199 | } 200 | 201 | QMenu::indicator { 202 | width: 18px; 203 | height: 18px; 204 | } 205 | 206 | 207 | /* non-exclusive indicator = check box style indicator 208 | (see QActionGroup::setExclusive) */ 209 | 210 | QMenu::indicator:non-exclusive:unchecked { 211 | image: url(:/qss_icons/rc/checkbox_unchecked.png); 212 | } 213 | 214 | QMenu::indicator:non-exclusive:unchecked:selected { 215 | image: url(:/qss_icons/rc/checkbox_unchecked_disabled.png); 216 | } 217 | 218 | QMenu::indicator:non-exclusive:checked { 219 | image: url(:/qss_icons/rc/checkbox_checked.png); 220 | } 221 | 222 | QMenu::indicator:non-exclusive:checked:selected { 223 | image: url(:/qss_icons/rc/checkbox_checked_disabled.png); 224 | } 225 | 226 | 227 | /* exclusive indicator = radio button style indicator (see QActionGroup::setExclusive) */ 228 | 229 | QMenu::indicator:exclusive:unchecked { 230 | image: url(:/qss_icons/rc/radio_unchecked.png); 231 | } 232 | 233 | QMenu::indicator:exclusive:unchecked:selected { 234 | image: url(:/qss_icons/rc/radio_unchecked_disabled.png); 235 | } 236 | 237 | QMenu::indicator:exclusive:checked { 238 | image: url(:/qss_icons/rc/radio_checked.png); 239 | } 240 | 241 | QMenu::indicator:exclusive:checked:selected { 242 | image: url(:/qss_icons/rc/radio_checked_disabled.png); 243 | } 244 | 245 | QMenu::right-arrow { 246 | margin: 5px; 247 | image: url(:/qss_icons/rc/right_arrow.png) 248 | } 249 | 250 | QWidget:disabled { 251 | color: #454545; 252 | background-color: #31363b; 253 | } 254 | 255 | QAbstractItemView { 256 | alternate-background-color: #31363b; 257 | color: #eff0f1; 258 | border: 1px solid #3A3939; 259 | border-radius: 2px; 260 | } 261 | 262 | QWidget:focus, 263 | QMenuBar:focus { 264 | border: 1px solid #3daee9; 265 | } 266 | 267 | QTabWidget:focus, 268 | QCheckBox:focus, 269 | QRadioButton:focus, 270 | QSlider:focus { 271 | border: none; 272 | } 273 | 274 | QLineEdit { 275 | background-color: #232629; 276 | padding: 5px; 277 | border-style: solid; 278 | border: 1px solid #76797C; 279 | border-radius: 2px; 280 | color: #eff0f1; 281 | } 282 | 283 | QAbstractItemView QLineEdit { 284 | padding: 0; 285 | } 286 | 287 | QGroupBox { 288 | border: 1px solid #76797C; 289 | border-radius: 2px; 290 | margin-top: 20px; 291 | } 292 | 293 | QGroupBox::title { 294 | subcontrol-origin: margin; 295 | subcontrol-position: top center; 296 | padding-left: 10px; 297 | padding-right: 10px; 298 | padding-top: 10px; 299 | } 300 | 301 | QAbstractScrollArea { 302 | border-radius: 2px; 303 | border: 1px solid #76797C; 304 | background-color: transparent; 305 | } 306 | 307 | QScrollBar:horizontal { 308 | height: 15px; 309 | margin: 3px 15px 3px 15px; 310 | border: 1px transparent #2A2929; 311 | border-radius: 4px; 312 | background-color: #2A2929; 313 | } 314 | 315 | QScrollBar::handle:horizontal { 316 | background-color: #605F5F; 317 | min-width: 5px; 318 | border-radius: 4px; 319 | } 320 | 321 | QScrollBar::add-line:horizontal { 322 | margin: 0px 3px 0px 3px; 323 | border-image: url(:/qss_icons/rc/right_arrow_disabled.png); 324 | width: 10px; 325 | height: 10px; 326 | subcontrol-position: right; 327 | subcontrol-origin: margin; 328 | } 329 | 330 | QScrollBar::sub-line:horizontal { 331 | margin: 0px 3px 0px 3px; 332 | border-image: url(:/qss_icons/rc/left_arrow_disabled.png); 333 | height: 10px; 334 | width: 10px; 335 | subcontrol-position: left; 336 | subcontrol-origin: margin; 337 | } 338 | 339 | QScrollBar::add-line:horizontal:hover, 340 | QScrollBar::add-line:horizontal:on { 341 | border-image: url(:/qss_icons/rc/right_arrow.png); 342 | height: 10px; 343 | width: 10px; 344 | subcontrol-position: right; 345 | subcontrol-origin: margin; 346 | } 347 | 348 | QScrollBar::sub-line:horizontal:hover, 349 | QScrollBar::sub-line:horizontal:on { 350 | border-image: url(:/qss_icons/rc/left_arrow.png); 351 | height: 10px; 352 | width: 10px; 353 | subcontrol-position: left; 354 | subcontrol-origin: margin; 355 | } 356 | 357 | QScrollBar::up-arrow:horizontal, 358 | QScrollBar::down-arrow:horizontal { 359 | background: none; 360 | } 361 | 362 | QScrollBar::add-page:horizontal, 363 | QScrollBar::sub-page:horizontal { 364 | background: none; 365 | } 366 | 367 | QScrollBar:vertical { 368 | background-color: #2A2929; 369 | width: 15px; 370 | margin: 15px 3px 15px 3px; 371 | border: 1px transparent #2A2929; 372 | border-radius: 4px; 373 | } 374 | 375 | QScrollBar::handle:vertical { 376 | background-color: #605F5F; 377 | min-height: 40px; 378 | border-radius: 4px; 379 | } 380 | 381 | QScrollBar::sub-line:vertical { 382 | margin: 3px 0px 3px 0px; 383 | border-image: url(:/qss_icons/rc/up_arrow_disabled.png); 384 | height: 10px; 385 | width: 10px; 386 | subcontrol-position: top; 387 | subcontrol-origin: margin; 388 | } 389 | 390 | QScrollBar::add-line:vertical { 391 | margin: 3px 0px 3px 0px; 392 | border-image: url(:/qss_icons/rc/down_arrow_disabled.png); 393 | height: 10px; 394 | width: 10px; 395 | subcontrol-position: bottom; 396 | subcontrol-origin: margin; 397 | } 398 | 399 | QScrollBar::sub-line:vertical:hover, 400 | QScrollBar::sub-line:vertical:on { 401 | border-image: url(:/qss_icons/rc/up_arrow.png); 402 | height: 10px; 403 | width: 10px; 404 | subcontrol-position: top; 405 | subcontrol-origin: margin; 406 | } 407 | 408 | QScrollBar::add-line:vertical:hover, 409 | QScrollBar::add-line:vertical:on { 410 | border-image: url(:/qss_icons/rc/down_arrow.png); 411 | height: 10px; 412 | width: 10px; 413 | subcontrol-position: bottom; 414 | subcontrol-origin: margin; 415 | } 416 | 417 | QScrollBar::up-arrow:vertical, 418 | QScrollBar::down-arrow:vertical { 419 | background: none; 420 | } 421 | 422 | QScrollBar::add-page:vertical, 423 | QScrollBar::sub-page:vertical { 424 | background: none; 425 | } 426 | 427 | QTextEdit { 428 | background-color: #232629; 429 | color: #eff0f1; 430 | border: 1px solid #76797C; 431 | } 432 | 433 | QPlainTextEdit { 434 | background-color: #232629; 435 | ; 436 | color: #eff0f1; 437 | border-radius: 2px; 438 | border: 1px solid #76797C; 439 | } 440 | 441 | QHeaderView::section { 442 | background-color: #76797C; 443 | color: #eff0f1; 444 | padding: 5px; 445 | border: 1px solid #76797C; 446 | } 447 | 448 | QSizeGrip { 449 | image: url(:/qss_icons/rc/sizegrip.png); 450 | width: 12px; 451 | height: 12px; 452 | } 453 | 454 | QMainWindow::separator { 455 | background-color: #31363b; 456 | color: white; 457 | padding-left: 4px; 458 | spacing: 2px; 459 | border: 1px dashed #76797C; 460 | } 461 | 462 | QMainWindow::separator:hover { 463 | background-color: #787876; 464 | color: white; 465 | padding-left: 4px; 466 | border: 1px solid #76797C; 467 | spacing: 2px; 468 | } 469 | 470 | QMenu::separator { 471 | height: 1px; 472 | background-color: #76797C; 473 | color: white; 474 | padding-left: 4px; 475 | margin-left: 10px; 476 | margin-right: 5px; 477 | } 478 | 479 | QFrame { 480 | border-radius: 2px; 481 | border: 1px solid #76797C; 482 | } 483 | 484 | QFrame[frameShape="0"] { 485 | border-radius: 2px; 486 | border: 1px transparent #76797C; 487 | } 488 | 489 | QStackedWidget { 490 | border: 1px transparent black; 491 | } 492 | 493 | QToolBar { 494 | border: 1px transparent #393838; 495 | background: 1px solid #31363b; 496 | font-weight: bold; 497 | } 498 | 499 | QToolBar::handle:horizontal { 500 | image: url(:/qss_icons/rc/Hmovetoolbar.png); 501 | } 502 | 503 | QToolBar::handle:vertical { 504 | image: url(:/qss_icons/rc/Vmovetoolbar.png); 505 | } 506 | 507 | QToolBar::separator:horizontal { 508 | image: url(:/qss_icons/rc/Hsepartoolbar.png); 509 | } 510 | 511 | QToolBar::separator:vertical { 512 | image: url(:/qss_icons/rc/Vsepartoolbar.png); 513 | } 514 | 515 | QToolButton#qt_toolbar_ext_button { 516 | background: #58595a 517 | } 518 | 519 | QPushButton { 520 | color: #eff0f1; 521 | background-color: #31363b; 522 | border-width: 1px; 523 | border-color: #76797C; 524 | border-style: solid; 525 | padding: 5px; 526 | border-radius: 2px; 527 | outline: none; 528 | } 529 | 530 | QPushButton:disabled { 531 | background-color: #31363b; 532 | border-width: 1px; 533 | border-color: #454545; 534 | border-style: solid; 535 | padding-top: 5px; 536 | padding-bottom: 5px; 537 | padding-left: 10px; 538 | padding-right: 10px; 539 | border-radius: 2px; 540 | color: #454545; 541 | } 542 | 543 | QPushButton:pressed { 544 | background-color: #3daee9; 545 | padding-top: -15px; 546 | padding-bottom: -17px; 547 | } 548 | 549 | QComboBox { 550 | selection-background-color: #3daee9; 551 | border-style: solid; 552 | border: 1px solid #76797C; 553 | border-radius: 2px; 554 | padding: 5px; 555 | } 556 | 557 | QPushButton:checked { 558 | background-color: #76797C; 559 | border-color: #6A6969; 560 | } 561 | 562 | QComboBox:hover, 563 | QPushButton:hover, 564 | QAbstractSpinBox:hover, 565 | QLineEdit:hover, 566 | QTextEdit:hover, 567 | QPlainTextEdit:hover, 568 | QAbstractView:hover, 569 | QTreeView:hover { 570 | border: 1px solid #3daee9; 571 | color: #eff0f1; 572 | } 573 | 574 | QComboBox:on { 575 | padding-top: 3px; 576 | padding-left: 4px; 577 | selection-background-color: #4a4a4a; 578 | } 579 | 580 | QComboBox QAbstractItemView { 581 | background-color: #232629; 582 | border-radius: 2px; 583 | border: 1px solid #76797C; 584 | selection-background-color: #18465d; 585 | } 586 | 587 | QComboBox::drop-down { 588 | subcontrol-origin: padding; 589 | subcontrol-position: top right; 590 | width: 15px; 591 | border-left-width: 0px; 592 | border-left-color: darkgray; 593 | border-left-style: solid; 594 | border-top-right-radius: 3px; 595 | border-bottom-right-radius: 3px; 596 | } 597 | 598 | QComboBox::down-arrow { 599 | image: url(:/qss_icons/rc/down_arrow_disabled.png); 600 | } 601 | 602 | QComboBox::down-arrow:on, 603 | QComboBox::down-arrow:hover, 604 | QComboBox::down-arrow:focus { 605 | image: url(:/qss_icons/rc/down_arrow.png); 606 | } 607 | 608 | QAbstractSpinBox { 609 | padding: 5px; 610 | border: 1px solid #76797C; 611 | background-color: #232629; 612 | color: #eff0f1; 613 | border-radius: 2px; 614 | min-width: 75px; 615 | } 616 | 617 | QAbstractSpinBox:up-button { 618 | background-color: transparent; 619 | subcontrol-origin: border; 620 | subcontrol-position: center right; 621 | } 622 | 623 | QAbstractSpinBox:down-button { 624 | background-color: transparent; 625 | subcontrol-origin: border; 626 | subcontrol-position: center left; 627 | } 628 | 629 | QAbstractSpinBox::up-arrow, 630 | QAbstractSpinBox::up-arrow:disabled, 631 | QAbstractSpinBox::up-arrow:off { 632 | image: url(:/qss_icons/rc/up_arrow_disabled.png); 633 | width: 10px; 634 | height: 10px; 635 | } 636 | 637 | QAbstractSpinBox::up-arrow:hover { 638 | image: url(:/qss_icons/rc/up_arrow.png); 639 | } 640 | 641 | QAbstractSpinBox::down-arrow, 642 | QAbstractSpinBox::down-arrow:disabled, 643 | QAbstractSpinBox::down-arrow:off { 644 | image: url(:/qss_icons/rc/down_arrow_disabled.png); 645 | width: 10px; 646 | height: 10px; 647 | } 648 | 649 | QAbstractSpinBox::down-arrow:hover { 650 | image: url(:/qss_icons/rc/down_arrow.png); 651 | } 652 | 653 | QLabel { 654 | border: 0px solid black; 655 | } 656 | 657 | QTabWidget { 658 | border: 0px transparent black; 659 | } 660 | 661 | QTabWidget::pane { 662 | border: 1px solid #76797C; 663 | padding: 5px; 664 | margin: 0px; 665 | } 666 | 667 | QTabWidget::tab-bar { 668 | /* left: 5px; move to the right by 5px */ 669 | } 670 | 671 | QTabBar { 672 | qproperty-drawBase: 0; 673 | border-radius: 3px; 674 | } 675 | 676 | QTabBar:focus { 677 | border: 0px transparent black; 678 | } 679 | 680 | QTabBar::close-button { 681 | image: url(:/qss_icons/rc/close.png); 682 | background: transparent; 683 | } 684 | 685 | QTabBar::close-button:hover { 686 | image: url(:/qss_icons/rc/close-hover.png); 687 | background: transparent; 688 | } 689 | 690 | QTabBar::close-button:pressed { 691 | image: url(:/qss_icons/rc/close-pressed.png); 692 | background: transparent; 693 | } 694 | 695 | 696 | /* TOP TABS */ 697 | 698 | QTabBar::tab:top { 699 | color: #eff0f1; 700 | border: 1px solid #76797C; 701 | border-bottom: 1px transparent black; 702 | background-color: #31363b; 703 | padding: 5px; 704 | min-width: 50px; 705 | border-top-left-radius: 2px; 706 | border-top-right-radius: 2px; 707 | } 708 | 709 | QTabBar::tab:top:selected { 710 | color: #eff0f1; 711 | background-color: #54575B; 712 | border: 1px solid #76797C; 713 | border-bottom: 2px solid #3daee9; 714 | border-top-left-radius: 2px; 715 | border-top-right-radius: 2px; 716 | } 717 | 718 | QTabBar::tab:top:!selected:hover { 719 | background-color: #3daee9; 720 | } 721 | 722 | 723 | /* BOTTOM TABS */ 724 | 725 | QTabBar::tab:bottom { 726 | color: #eff0f1; 727 | border: 1px solid #76797C; 728 | border-top: 1px transparent black; 729 | background-color: #31363b; 730 | padding: 5px; 731 | border-bottom-left-radius: 2px; 732 | border-bottom-right-radius: 2px; 733 | min-width: 50px; 734 | } 735 | 736 | QTabBar::tab:bottom:selected { 737 | color: #eff0f1; 738 | background-color: #54575B; 739 | border: 1px solid #76797C; 740 | border-top: 2px solid #3daee9; 741 | border-bottom-left-radius: 2px; 742 | border-bottom-right-radius: 2px; 743 | } 744 | 745 | QTabBar::tab:bottom:!selected:hover { 746 | background-color: #3daee9; 747 | } 748 | 749 | 750 | /* LEFT TABS */ 751 | 752 | QTabBar::tab:left { 753 | color: #eff0f1; 754 | border: 1px solid #76797C; 755 | border-left: 1px transparent black; 756 | background-color: #31363b; 757 | padding: 5px; 758 | border-top-right-radius: 2px; 759 | border-bottom-right-radius: 2px; 760 | min-height: 50px; 761 | } 762 | 763 | QTabBar::tab:left:selected { 764 | color: #eff0f1; 765 | background-color: #54575B; 766 | border: 1px solid #76797C; 767 | border-left: 2px solid #3daee9; 768 | border-top-right-radius: 2px; 769 | border-bottom-right-radius: 2px; 770 | } 771 | 772 | QTabBar::tab:left:!selected:hover { 773 | background-color: #3daee9; 774 | } 775 | 776 | 777 | /* RIGHT TABS */ 778 | 779 | QTabBar::tab:right { 780 | color: #eff0f1; 781 | border: 1px solid #76797C; 782 | border-right: 1px transparent black; 783 | background-color: #31363b; 784 | padding: 5px; 785 | border-top-left-radius: 2px; 786 | border-bottom-left-radius: 2px; 787 | min-height: 50px; 788 | } 789 | 790 | QTabBar::tab:right:selected { 791 | color: #eff0f1; 792 | background-color: #54575B; 793 | border: 1px solid #76797C; 794 | border-right: 2px solid #3daee9; 795 | border-top-left-radius: 2px; 796 | border-bottom-left-radius: 2px; 797 | } 798 | 799 | QTabBar::tab:right:!selected:hover { 800 | background-color: #3daee9; 801 | } 802 | 803 | QTabBar QToolButton::right-arrow:enabled { 804 | image: url(:/qss_icons/rc/right_arrow.png); 805 | } 806 | 807 | QTabBar QToolButton::left-arrow:enabled { 808 | image: url(:/qss_icons/rc/left_arrow.png); 809 | } 810 | 811 | QTabBar QToolButton::right-arrow:disabled { 812 | image: url(:/qss_icons/rc/right_arrow_disabled.png); 813 | } 814 | 815 | QTabBar QToolButton::left-arrow:disabled { 816 | image: url(:/qss_icons/rc/left_arrow_disabled.png); 817 | } 818 | 819 | QDockWidget { 820 | background: #31363b; 821 | border: 1px solid #403F3F; 822 | titlebar-close-icon: url(:/qss_icons/rc/close.png); 823 | titlebar-normal-icon: url(:/qss_icons/rc/undock.png); 824 | } 825 | 826 | QDockWidget::close-button, 827 | QDockWidget::float-button { 828 | border: 1px solid transparent; 829 | border-radius: 2px; 830 | background: transparent; 831 | } 832 | 833 | QDockWidget::close-button:hover, 834 | QDockWidget::float-button:hover { 835 | background: rgba(255, 255, 255, 10); 836 | } 837 | 838 | QDockWidget::close-button:pressed, 839 | QDockWidget::float-button:pressed { 840 | padding: 1px -1px -1px 1px; 841 | background: rgba(255, 255, 255, 10); 842 | } 843 | 844 | QTreeView, 845 | QListView { 846 | border: 1px solid #76797C; 847 | background-color: #232629; 848 | } 849 | 850 | QTreeView:branch:selected, 851 | QTreeView:branch:hover { 852 | background: url(:/qss_icons/rc/transparent.png); 853 | } 854 | 855 | QTreeView::branch:has-siblings:!adjoins-item { 856 | border-image: url(:/qss_icons/rc/transparent.png); 857 | } 858 | 859 | QTreeView::branch:has-siblings:adjoins-item { 860 | border-image: url(:/qss_icons/rc/transparent.png); 861 | } 862 | 863 | QTreeView::branch:!has-children:!has-siblings:adjoins-item { 864 | border-image: url(:/qss_icons/rc/transparent.png); 865 | } 866 | 867 | QTreeView::branch:has-children:!has-siblings:closed, 868 | QTreeView::branch:closed:has-children:has-siblings { 869 | image: url(:/qss_icons/rc/branch_closed.png); 870 | } 871 | 872 | QTreeView::branch:open:has-children:!has-siblings, 873 | QTreeView::branch:open:has-children:has-siblings { 874 | image: url(:/qss_icons/rc/branch_open.png); 875 | } 876 | 877 | QTreeView::branch:has-children:!has-siblings:closed:hover, 878 | QTreeView::branch:closed:has-children:has-siblings:hover { 879 | image: url(:/qss_icons/rc/branch_closed-on.png); 880 | } 881 | 882 | QTreeView::branch:open:has-children:!has-siblings:hover, 883 | QTreeView::branch:open:has-children:has-siblings:hover { 884 | image: url(:/qss_icons/rc/branch_open-on.png); 885 | } 886 | 887 | QListView::item:!selected:hover, 888 | QTreeView::item:!selected:hover { 889 | background: #18465d; 890 | outline: 0; 891 | color: #eff0f1 892 | } 893 | 894 | QListView::item:selected:hover, 895 | QTreeView::item:selected:hover { 896 | background: #287399; 897 | color: #eff0f1; 898 | } 899 | 900 | QTreeView::indicator:checked, 901 | QListView::indicator:checked { 902 | image: url(:/qss_icons/rc/checkbox_checked.png); 903 | } 904 | 905 | QTreeView::indicator:unchecked, 906 | QListView::indicator:unchecked { 907 | image: url(:/qss_icons/rc/checkbox_unchecked.png); 908 | } 909 | 910 | QTreeView::indicator:indeterminate, 911 | QListView::indicator:indeterminate { 912 | image: url(:/qss_icons/rc/checkbox_indeterminate.png); 913 | } 914 | 915 | QTreeView::indicator:checked:hover, 916 | QTreeView::indicator:checked:focus, 917 | QTreeView::indicator:checked:pressed, 918 | QListView::indicator:checked:hover, 919 | QListView::indicator:checked:focus, 920 | QListView::indicator:checked:pressed { 921 | image: url(:/qss_icons/rc/checkbox_checked_focus.png); 922 | } 923 | 924 | QTreeView::indicator:unchecked:hover, 925 | QTreeView::indicator:unchecked:focus, 926 | QTreeView::indicator:unchecked:pressed, 927 | QListView::indicator:unchecked:hover, 928 | QListView::indicator:unchecked:focus, 929 | QListView::indicator:unchecked:pressed { 930 | image: url(:/qss_icons/rc/checkbox_unchecked_focus.png); 931 | } 932 | 933 | QTreeView::indicator:indeterminate:hover, 934 | QTreeView::indicator:indeterminate:focus, 935 | QTreeView::indicator:indeterminate:pressed, 936 | QListView::indicator:indeterminate:hover, 937 | QListView::indicator:indeterminate:focus, 938 | QListView::indicator:indeterminate:pressed { 939 | image: url(:/qss_icons/rc/checkbox_indeterminate_focus.png); 940 | } 941 | 942 | QSlider::groove:horizontal { 943 | border: 1px solid #565a5e; 944 | height: 4px; 945 | background: #565a5e; 946 | margin: 0px; 947 | border-radius: 2px; 948 | } 949 | 950 | QSlider::handle:horizontal { 951 | background: #232629; 952 | border: 1px solid #565a5e; 953 | width: 16px; 954 | height: 16px; 955 | margin: -8px 0; 956 | border-radius: 9px; 957 | } 958 | 959 | QSlider::groove:vertical { 960 | border: 1px solid #565a5e; 961 | width: 4px; 962 | background: #565a5e; 963 | margin: 0px; 964 | border-radius: 3px; 965 | } 966 | 967 | QSlider::handle:vertical { 968 | background: #232629; 969 | border: 1px solid #565a5e; 970 | width: 16px; 971 | height: 16px; 972 | margin: 0 -8px; 973 | border-radius: 9px; 974 | } 975 | 976 | QToolButton { 977 | background-color: transparent; 978 | border: 1px transparent #76797C; 979 | border-radius: 2px; 980 | margin: 3px; 981 | padding: 5px; 982 | } 983 | 984 | QToolButton[popupMode="1"] { 985 | /* only for MenuButtonPopup */ 986 | padding-right: 20px; 987 | /* make way for the popup button */ 988 | border: 1px #76797C; 989 | border-radius: 5px; 990 | } 991 | 992 | QToolButton[popupMode="2"] { 993 | /* only for InstantPopup */ 994 | padding-right: 10px; 995 | /* make way for the popup button */ 996 | border: 1px #76797C; 997 | } 998 | 999 | QToolButton:hover, 1000 | QToolButton::menu-button:hover { 1001 | background-color: transparent; 1002 | border: 1px solid #3daee9; 1003 | padding: 5px; 1004 | } 1005 | 1006 | QToolButton:checked, 1007 | QToolButton:pressed, 1008 | QToolButton::menu-button:pressed { 1009 | background-color: #3daee9; 1010 | border: 1px solid #3daee9; 1011 | padding: 5px; 1012 | } 1013 | 1014 | 1015 | /* the subcontrol below is used only in the InstantPopup or DelayedPopup mode */ 1016 | 1017 | QToolButton::menu-indicator { 1018 | image: url(:/qss_icons/rc/down_arrow.png); 1019 | top: -7px; 1020 | left: -2px; 1021 | /* shift it a bit */ 1022 | } 1023 | 1024 | 1025 | /* the subcontrols below are used only in the MenuButtonPopup mode */ 1026 | 1027 | QToolButton::menu-button { 1028 | border: 1px transparent #76797C; 1029 | border-top-right-radius: 6px; 1030 | border-bottom-right-radius: 6px; 1031 | /* 16px width + 4px for border = 20px allocated above */ 1032 | width: 16px; 1033 | outline: none; 1034 | } 1035 | 1036 | QToolButton::menu-arrow { 1037 | image: url(:/qss_icons/rc/down_arrow.png); 1038 | } 1039 | 1040 | QToolButton::menu-arrow:open { 1041 | border: 1px solid #76797C; 1042 | } 1043 | 1044 | QPushButton::menu-indicator { 1045 | subcontrol-origin: padding; 1046 | subcontrol-position: bottom right; 1047 | left: 8px; 1048 | } 1049 | 1050 | QTableView { 1051 | border: 1px solid #76797C; 1052 | gridline-color: #383f46; 1053 | background-color: #232629; 1054 | } 1055 | 1056 | QTableView, 1057 | QHeaderView { 1058 | border-radius: 0px; 1059 | } 1060 | 1061 | QTableView::item:pressed, 1062 | QListView::item:pressed, 1063 | QTreeView::item:pressed { 1064 | background: #18465d; 1065 | color: #eff0f1; 1066 | } 1067 | 1068 | QTableView::item:selected:active, 1069 | QTreeView::item:selected:active, 1070 | QListView::item:selected:active { 1071 | background: #287399; 1072 | color: #eff0f1; 1073 | } 1074 | 1075 | QHeaderView { 1076 | background-color: #31363b; 1077 | border: 1px transparent; 1078 | border-radius: 0px; 1079 | margin: 0px; 1080 | padding: 0px; 1081 | } 1082 | 1083 | QHeaderView::section { 1084 | background-color: #31363b; 1085 | color: #eff0f1; 1086 | padding: 5px; 1087 | border: 1px solid #76797C; 1088 | border-radius: 0px; 1089 | text-align: center; 1090 | } 1091 | 1092 | QHeaderView::section::vertical::first, 1093 | QHeaderView::section::vertical::only-one { 1094 | border-top: 1px solid #76797C; 1095 | } 1096 | 1097 | QHeaderView::section::vertical { 1098 | border-top: transparent; 1099 | } 1100 | 1101 | QHeaderView::section::horizontal::first, 1102 | QHeaderView::section::horizontal::only-one { 1103 | border-left: 1px solid #76797C; 1104 | } 1105 | 1106 | QHeaderView::section::horizontal { 1107 | border-left: transparent; 1108 | } 1109 | 1110 | QHeaderView::section:checked { 1111 | color: white; 1112 | background-color: #334e5e; 1113 | } 1114 | 1115 | 1116 | /* style the sort indicator */ 1117 | 1118 | QHeaderView::down-arrow { 1119 | image: url(:/qss_icons/rc/down_arrow.png); 1120 | } 1121 | 1122 | QHeaderView::up-arrow { 1123 | image: url(:/qss_icons/rc/up_arrow.png); 1124 | } 1125 | 1126 | QTableCornerButton::section { 1127 | background-color: #31363b; 1128 | border: 1px transparent #76797C; 1129 | border-radius: 0px; 1130 | } 1131 | 1132 | QToolBox { 1133 | padding: 5px; 1134 | border: 1px transparent black; 1135 | } 1136 | 1137 | QToolBox::tab { 1138 | color: #eff0f1; 1139 | background-color: #31363b; 1140 | border: 1px solid #76797C; 1141 | border-bottom: 1px transparent #31363b; 1142 | border-top-left-radius: 5px; 1143 | border-top-right-radius: 5px; 1144 | } 1145 | 1146 | QToolBox::tab:selected { 1147 | /* italicize selected tabs */ 1148 | font: italic; 1149 | background-color: #31363b; 1150 | border-color: #3daee9; 1151 | } 1152 | 1153 | QStatusBar::item { 1154 | border: 0px transparent dark; 1155 | } 1156 | 1157 | QFrame[height="3"], 1158 | QFrame[width="3"] { 1159 | background-color: #76797C; 1160 | } 1161 | 1162 | QSplitter::handle { 1163 | border: 1px dashed #76797C; 1164 | } 1165 | 1166 | QSplitter::handle:hover { 1167 | background-color: #787876; 1168 | border: 1px solid #76797C; 1169 | } 1170 | 1171 | QSplitter::handle:horizontal { 1172 | width: 1px; 1173 | } 1174 | 1175 | QSplitter::handle:vertical { 1176 | height: 1px; 1177 | } 1178 | 1179 | QProgressBar { 1180 | border: 1px solid #76797C; 1181 | border-radius: 5px; 1182 | text-align: center; 1183 | } 1184 | 1185 | QProgressBar::chunk { 1186 | background-color: #05B8CC; 1187 | } 1188 | 1189 | QDateEdit { 1190 | selection-background-color: #3daee9; 1191 | border-style: solid; 1192 | border: 1px solid #3375A3; 1193 | border-radius: 2px; 1194 | padding: 1px; 1195 | min-width: 75px; 1196 | } 1197 | 1198 | QDateEdit:on { 1199 | padding-top: 3px; 1200 | padding-left: 4px; 1201 | selection-background-color: #4a4a4a; 1202 | } 1203 | 1204 | QDateEdit QAbstractItemView { 1205 | background-color: #232629; 1206 | border-radius: 2px; 1207 | border: 1px solid #3375A3; 1208 | selection-background-color: #3daee9; 1209 | } 1210 | 1211 | QDateEdit::drop-down { 1212 | subcontrol-origin: padding; 1213 | subcontrol-position: top right; 1214 | width: 15px; 1215 | border-left-width: 0px; 1216 | border-left-color: darkgray; 1217 | border-left-style: solid; 1218 | border-top-right-radius: 3px; 1219 | border-bottom-right-radius: 3px; 1220 | } 1221 | 1222 | QDateEdit::down-arrow { 1223 | image: url(:/qss_icons/rc/down_arrow_disabled.png); 1224 | } 1225 | 1226 | QDateEdit::down-arrow:on, 1227 | QDateEdit::down-arrow:hover, 1228 | QDateEdit::down-arrow:focus { 1229 | image: url(:/qss_icons/rc/down_arrow.png); 1230 | } -------------------------------------------------------------------------------- /resources/splash.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/U1805/Hare/c0a66f0d249e3bf523f9abd947196cd9c675d8e7/resources/splash.png -------------------------------------------------------------------------------- /script/env_checker.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import importlib.metadata 3 | import os 4 | import subprocess 5 | import sys 6 | from pathlib import Path 7 | 8 | from PyQt5 import QtCore, QtGui, QtWidgets 9 | 10 | DEBUG = True 11 | 12 | python_executable = ( 13 | os.path.join(os.path.dirname(sys.executable), "runtime", "python.exe") 14 | if not DEBUG 15 | else sys.executable 16 | ) 17 | 18 | 19 | class InstallThread(QtCore.QThread): 20 | progress_signal = QtCore.pyqtSignal(str) 21 | status_signal = QtCore.pyqtSignal(str) 22 | 23 | def __init__(self, packages): 24 | super().__init__() 25 | self.packages = packages 26 | 27 | def run(self): 28 | """Executes package installation tasks in a background thread""" 29 | self.progress_signal.emit("pip") 30 | self.status_signal.emit("检查 pip...") 31 | self.enable_pip() 32 | 33 | # Install torch (only if NVIDIA driver is detected) 34 | driver_version = get_nvidia_driver_version() 35 | if driver_version: 36 | for pkg in self.packages[:2]: 37 | package_name = pkg["package_name"] 38 | version = pkg.get("version") 39 | import_name = pkg.get("import_name") 40 | self.check_and_install_package(package_name, import_name, version) 41 | 42 | # torch = importlib.import_module("torch") 43 | # device = "cuda" if torch.cuda.is_available() else "cpu" 44 | # self.status_signal.emit(f"Using device: {device}") 45 | else: 46 | self.progress_signal.emit("torch") 47 | self.progress_signal.emit("torchvision") 48 | self.status_signal.emit("未检测到 NVIDIA 驱动。请检查环境配置。") 49 | 50 | # Install other packages 51 | for pkg in self.packages[2:]: 52 | package_name = pkg["package_name"] 53 | version = pkg.get("version") 54 | import_name = pkg.get("import_name") 55 | self.check_and_install_package(package_name, import_name, version) 56 | 57 | self.progress_signal.emit("big-lama.pt") 58 | self.status_signal.emit("检查 big-lama.pt...") 59 | if driver_version: 60 | self.download_model() 61 | 62 | clear_command = [python_executable, "-m", "pip", "cache", "purge"] 63 | self.run_command(clear_command, "清理 pip 缓存...") 64 | self.progress_signal.emit("complete") 65 | 66 | def check_and_install_package(self, package_name, import_name, version=None): 67 | """Checks and installs the specified package and version""" 68 | self.progress_signal.emit(package_name) 69 | try: 70 | if version: 71 | pkg_version = importlib.metadata.version(package_name) 72 | if pkg_version == version: 73 | self.status_signal.emit(f"{package_name} 版本 {version} 已安装") 74 | return importlib.import_module(import_name) 75 | else: 76 | importlib.metadata.version(import_name) 77 | self.status_signal.emit(f"{package_name} 已安装") 78 | return importlib.import_module(import_name) 79 | except importlib.metadata.PackageNotFoundError: 80 | if version: 81 | package_with_version = f"{package_name}=={version}" 82 | self.status_signal.emit(f"安装 {package_with_version}...") 83 | else: 84 | package_with_version = package_name 85 | self.status_signal.emit(f"安装最新版本的 {package_name}...") 86 | self.install_package_with_fallback(package_with_version) 87 | 88 | def install_package_with_fallback(self, package_with_version): 89 | """Tries to install the package with Tsinghua mirror; falls back to default mirror on failure""" 90 | command = [python_executable, "-m", "pip", "install", package_with_version] 91 | command += ["-i", "https://pypi.tuna.tsinghua.edu.cn/simple"] 92 | 93 | try: 94 | if package_with_version.startswith("torch"): 95 | command = command[:-2] 96 | command += ["-i", "https://download.pytorch.org/whl/cu118"] 97 | command += ["--trusted-host", "pypi.tuna.tsinghua.edu.cn"] 98 | self.run_command(command, f"从清华源安装 {package_with_version}...") 99 | except subprocess.CalledProcessError: 100 | command = command[:-2] 101 | self.run_command( 102 | command, 103 | f"清华源安装失败,尝试从默认源安装 {package_with_version}...", 104 | ) 105 | 106 | def enable_pip(self): 107 | """Checks if pip is installed, and downloads and installs it if missing""" 108 | try: 109 | self.status_signal.emit("检查 pip") 110 | subprocess.check_call( 111 | [python_executable, "-m", "pip", "--version"], 112 | startupinfo=subprocess.STARTUPINFO(), 113 | creationflags=subprocess.CREATE_NEW_CONSOLE, 114 | ) 115 | except subprocess.CalledProcessError: 116 | # get_pip_url = "https://bootstrap.pypa.io/get-pip.py" 117 | get_pip_url = "http://mirrors.aliyun.com/pypi/get-pip.py" 118 | get_pip_path = os.path.join(os.path.dirname(sys.executable), "get-pip.py") 119 | command = ["curl", "-L", "-o", get_pip_path, get_pip_url] 120 | self.run_command(command, f"下载 get-pip.py...") 121 | 122 | startupinfo = subprocess.STARTUPINFO() 123 | startupinfo.dwFlags &= ~subprocess.STARTF_USESHOWWINDOW 124 | process = subprocess.Popen( 125 | [python_executable, get_pip_path], 126 | startupinfo=startupinfo, 127 | creationflags=subprocess.CREATE_NEW_CONSOLE, 128 | ) 129 | process.wait() 130 | os.remove(get_pip_path) 131 | 132 | def download_model(self): 133 | if os.path.exists("big-lama.pt"): 134 | return 135 | 136 | model_url = "https://github.com/enesmsahin/simple-lama-inpainting/releases/download/v0.1.0/big-lama.pt" 137 | accelerated_urls = ["https://gh.llkk.cc/", "https://github.moeyy.xyz/"] 138 | get_model_path = "big-lama.pt" 139 | 140 | # 尝试加速 URL 141 | for url in accelerated_urls: 142 | try: 143 | command = ["curl", "-L", "-o", get_model_path, url + model_url] 144 | self.run_command(command, f"尝试从 {url} 下载模型...") 145 | self.status_signal.emit("下载成功!") 146 | return 147 | except Exception as e: 148 | self.status_signal.emit(f"从 {url} 下载失败: {e}") 149 | # 如果所有加速链接都失败,使用原始链接 150 | try: 151 | command = ["curl", "-L", "-o", get_model_path, model_url] 152 | self.run_command(command, f"尝试从原始 URL 下载模型...") 153 | self.status_signal.emit("下载成功!") 154 | except Exception as e: 155 | self.status_signal.emit(f"从原始 URL 下载失败: {e}") 156 | 157 | def run_command(self, command, status_message): 158 | """Runs a command and shows terminal window""" 159 | self.status_signal.emit(status_message) 160 | # Show terminal window when running pip commands 161 | startupinfo = subprocess.STARTUPINFO() 162 | startupinfo.dwFlags &= ~subprocess.STARTF_USESHOWWINDOW 163 | process = subprocess.Popen( 164 | command, 165 | startupinfo=startupinfo, 166 | creationflags=subprocess.CREATE_NEW_CONSOLE, 167 | ) 168 | process.wait() 169 | 170 | 171 | class InstallProgressWindow(QtWidgets.QWidget): 172 | def __init__(self, packages): 173 | super().__init__() 174 | self.setWindowTitle("软件包安装进度") 175 | self.setGeometry(400, 400, 400, 150) 176 | 177 | # Set up labels and progress bar 178 | self.label = QtWidgets.QLabel("准备安装所需软件包...", self) 179 | self.label.setGeometry(30, 20, 340, 30) 180 | self.progress_bar = QtWidgets.QProgressBar(self) 181 | self.progress_bar.setGeometry(30, 70, 340, 30) 182 | 183 | # Set progress bar range 184 | self.progress_bar.setMaximum(len(packages) + 2) # pip、lama.pt 185 | self.progress = 0 186 | 187 | def update_progress(self, package_name): 188 | if self.progress >= self.progress_bar.maximum(): 189 | self.final_message() 190 | else: 191 | self.label.setText(f"正在安装 {package_name}...") 192 | self.progress_bar.setValue(self.progress) 193 | QtWidgets.QApplication.processEvents() 194 | self.progress += 1 195 | 196 | def update_status(self, message): 197 | """Updates the status label with current operation""" 198 | self.label.setText(message) 199 | QtWidgets.QApplication.processEvents() 200 | 201 | def final_message(self): 202 | """Updates the progress bar to completion and shows a final message""" 203 | self.label.setText("环境配置完成。请重新启动。") 204 | self.progress_bar.setValue(self.progress_bar.maximum()) 205 | QtWidgets.QApplication.processEvents() 206 | 207 | 208 | def get_nvidia_driver_version(): 209 | """Retrieves NVIDIA driver version""" 210 | try: 211 | output = subprocess.check_output( 212 | ["nvidia-smi"], stderr=subprocess.STDOUT 213 | ).decode("utf-8") 214 | for line in output.split("\n"): 215 | if "Driver Version" in line: 216 | version = line.split("Driver Version:")[1].strip().split(" ")[0] 217 | return version 218 | except Exception as e: 219 | print("无法检测到 NVIDIA 驱动版本:", e) 220 | 221 | 222 | def install_window(packages): 223 | QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_UseHighDpiPixmaps, True) 224 | QtWidgets.QApplication.setAttribute(QtCore.Qt.AA_EnableHighDpiScaling, True) 225 | app = QtWidgets.QApplication(sys.argv) 226 | resources_path = Path("resources") 227 | 228 | qss_path = resources_path / "qdark.qss" 229 | with open(qss_path, "r") as qss_file: 230 | app.setStyleSheet(qss_file.read()) 231 | app.setFont(QtGui.QFont("Microsoft YaHei", 9)) 232 | 233 | progress_window = InstallProgressWindow(packages) 234 | progress_window.setWindowFlags( 235 | progress_window.windowFlags() | QtCore.Qt.WindowStaysOnTopHint 236 | ) 237 | progress_window.show() 238 | install_thread = InstallThread(packages) 239 | install_thread.progress_signal.connect(progress_window.update_progress) 240 | install_thread.status_signal.connect(progress_window.update_status) 241 | install_thread.start() 242 | 243 | sys.exit(app.exec_()) 244 | 245 | 246 | def main(): 247 | packages = [ 248 | {"package_name": "torch", "import_name": "torch"}, 249 | { 250 | "package_name": "torchvision", 251 | "import_name": "torchvision", 252 | }, 253 | {"package_name": "numpy", "import_name": "numpy", "version": "1.24.4"}, 254 | { 255 | "package_name": "opencv-contrib-python-headless", 256 | "import_name": "cv2", 257 | "version": "4.10.0.84", 258 | }, 259 | ] 260 | 261 | missing_packages = [] 262 | for pkg in packages: 263 | try: 264 | importlib.import_module(pkg["import_name"]) 265 | except ImportError: 266 | print(f"{pkg['import_name']} 未安装") 267 | missing_packages.append(pkg) 268 | 269 | if missing_packages: 270 | print("检测到部分包未安装,开始安装...") 271 | install_window(packages) 272 | return False 273 | else: 274 | print("所有包已成功导入,无需安装。") 275 | return True 276 | 277 | 278 | if __name__ == "__main__": 279 | main() 280 | -------------------------------------------------------------------------------- /script/inpaint/fsr_parallel.py: -------------------------------------------------------------------------------- 1 | import threading 2 | from concurrent.futures import ThreadPoolExecutor, as_completed 3 | 4 | import cv2 5 | import numpy as np 6 | 7 | 8 | class SharedNDArray: 9 | def __init__(self, arr): 10 | self.arr = arr 11 | self.lock = threading.Lock() 12 | 13 | 14 | def inpaint_block(args): 15 | shared_src, shared_mask, shared_result, y_start, y_end, x_start, x_end, overlap = ( 16 | args 17 | ) 18 | 19 | # Define extended region with overlap 20 | y_start_ext = max(0, y_start - overlap) 21 | y_end_ext = min(shared_src.arr.shape[0], y_end + overlap) 22 | x_start_ext = max(0, x_start - overlap) 23 | x_end_ext = min(shared_src.arr.shape[1], x_end + overlap) 24 | 25 | # Extract extended block from source and mask 26 | block = shared_src.arr[y_start_ext:y_end_ext, x_start_ext:x_end_ext] 27 | mask_block = shared_mask.arr[y_start_ext:y_end_ext, x_start_ext:x_end_ext] 28 | 29 | # Perform inpainting on the extended block 30 | cv2.xphoto.inpaint(block, mask_block, block, cv2.xphoto.INPAINT_FSR_FAST) 31 | 32 | # Calculate the actual overlap for trimming, especially near the edges 33 | y_overlap_top = y_start - y_start_ext 34 | y_overlap_bottom = y_end_ext - y_end 35 | x_overlap_left = x_start - x_start_ext 36 | x_overlap_right = x_end_ext - x_end 37 | 38 | # Insert the inpainted result back into the result array, trimming the overlap 39 | with shared_result.lock: 40 | shared_result.arr[y_start:y_end, x_start:x_end] = block[ 41 | y_overlap_top : block.shape[0] - y_overlap_bottom, 42 | x_overlap_left : block.shape[1] - x_overlap_right, 43 | ] 44 | 45 | 46 | def fsr(src, mask, num_threads=16, block_size=64, overlap=16): 47 | height, width = src.shape[:2] 48 | 49 | # Create shared arrays 50 | shared_src = SharedNDArray(src) 51 | shared_mask = SharedNDArray(cv2.bitwise_not(mask)) 52 | shared_result = SharedNDArray(np.zeros_like(src)) 53 | 54 | # Generate block coordinates 55 | blocks = [ 56 | ( 57 | shared_src, 58 | shared_mask, 59 | shared_result, 60 | y, 61 | min(y + block_size, height), 62 | x, 63 | min(x + block_size, width), 64 | overlap, 65 | ) 66 | for y in range(0, height, block_size) 67 | for x in range(0, width, block_size) 68 | ] 69 | 70 | # Process blocks using ThreadPoolExecutor 71 | with ThreadPoolExecutor(max_workers=num_threads) as executor: 72 | futures = [executor.submit(inpaint_block, block) for block in blocks] 73 | for future in as_completed(futures): 74 | future.result() # Ensures that exceptions are raised if any 75 | 76 | return shared_result.arr 77 | -------------------------------------------------------------------------------- /script/inpaint/lama.py: -------------------------------------------------------------------------------- 1 | import os 2 | import subprocess 3 | 4 | import cv2 5 | import numpy as np 6 | import torch 7 | 8 | 9 | def get_image(image): 10 | img = image.copy() 11 | 12 | if img.ndim == 3: 13 | img = np.transpose(img, (2, 0, 1)) # chw 14 | elif img.ndim == 2: 15 | img = img[np.newaxis, ...] 16 | 17 | assert img.ndim == 3 18 | 19 | img = img.astype(np.float32) / 255 20 | return img 21 | 22 | 23 | def ceil_modulo(x, mod): 24 | if x % mod == 0: 25 | return x 26 | return (x // mod + 1) * mod 27 | 28 | 29 | def scale_image(img, factor, interpolation=cv2.INTER_AREA): 30 | if img.shape[0] == 1: 31 | img = img[0] 32 | else: 33 | img = np.transpose(img, (1, 2, 0)) 34 | 35 | img = cv2.resize(img, dsize=None, fx=factor, fy=factor, interpolation=interpolation) 36 | 37 | if img.ndim == 2: 38 | img = img[None, ...] 39 | else: 40 | img = np.transpose(img, (2, 0, 1)) 41 | return img 42 | 43 | 44 | def pad_img_to_modulo(img, mod): 45 | channels, height, width = img.shape 46 | out_height = ceil_modulo(height, mod) 47 | out_width = ceil_modulo(width, mod) 48 | return np.pad( 49 | img, 50 | ((0, 0), (0, out_height - height), (0, out_width - width)), 51 | mode="symmetric", 52 | ) 53 | 54 | 55 | def prepare_img_and_mask(image, mask, device, pad_out_to_modulo=8, scale_factor=None): 56 | out_image = get_image(image) 57 | out_mask = get_image(mask) 58 | 59 | if scale_factor is not None: 60 | out_image = scale_image(out_image, scale_factor) 61 | out_mask = scale_image(out_mask, scale_factor, interpolation=cv2.INTER_NEAREST) 62 | 63 | if pad_out_to_modulo is not None and pad_out_to_modulo > 1: 64 | out_image = pad_img_to_modulo(out_image, pad_out_to_modulo) 65 | out_mask = pad_img_to_modulo(out_mask, pad_out_to_modulo) 66 | 67 | out_image = torch.from_numpy(out_image).unsqueeze(0).to(device) 68 | out_mask = torch.from_numpy(out_mask).unsqueeze(0).to(device) 69 | 70 | out_mask = (out_mask > 0) * 1 71 | 72 | return out_image, out_mask 73 | 74 | 75 | def run_command(command, status_message): 76 | """Runs a command and shows terminal window""" 77 | print(status_message) 78 | # Show terminal window when running pip commands 79 | startupinfo = subprocess.STARTUPINFO() 80 | startupinfo.dwFlags &= ~subprocess.STARTF_USESHOWWINDOW 81 | process = subprocess.Popen( 82 | command, 83 | startupinfo=startupinfo, 84 | creationflags=subprocess.CREATE_NEW_CONSOLE, 85 | ) 86 | process.wait() 87 | 88 | 89 | def download_model(): 90 | model_url = "https://github.com/enesmsahin/simple-lama-inpainting/releases/download/v0.1.0/big-lama.pt" 91 | accelerated_urls = ["https://gh.llkk.cc/", "https://github.moeyy.xyz/"] 92 | get_model_path = "big-lama.pt" 93 | 94 | # 尝试加速 URL 95 | for url in accelerated_urls: 96 | try: 97 | command = ["curl", "-L", "-o", get_model_path, url + model_url] 98 | run_command(command, f"尝试从 {url} 下载模型...") 99 | print("下载成功!") 100 | return 101 | except Exception as e: 102 | print(f"从 {url} 下载失败: {e}") 103 | # 如果所有加速链接都失败,使用原始链接 104 | try: 105 | command = ["curl", "-L", "-o", get_model_path, model_url] 106 | run_command(command, f"尝试从原始 URL 下载模型...") 107 | print("下载成功!") 108 | except Exception as e: 109 | print(f"从原始 URL 下载失败: {e}") 110 | 111 | 112 | class SimpleLama: 113 | def __init__(self, model_path="big-lama.pt") -> None: 114 | 115 | if not os.path.exists(model_path): 116 | try: 117 | download_model() 118 | except: 119 | raise FileNotFoundError( 120 | f"lama model not found: {model_path}\nplease download from https://github.com/enesmsahin/simple-lama-inpainting/releases/download/v0.1.0/big-lama.pt" 121 | ) 122 | 123 | device = torch.device("cuda" if torch.cuda.is_available() else "cpu") 124 | self.model = torch.jit.load(model_path, map_location=device) 125 | self.model.eval() 126 | self.model.to(device) 127 | self.device = device 128 | 129 | def __call__(self, image, mask): 130 | image, mask = prepare_img_and_mask(image, mask, self.device) 131 | 132 | with torch.inference_mode(): 133 | inpainted = self.model(image, mask) 134 | 135 | cur_res = inpainted[0].permute(1, 2, 0).detach().cpu().numpy() 136 | cur_res = np.clip(cur_res * 255, 0, 255).astype(np.uint8) 137 | return cur_res 138 | -------------------------------------------------------------------------------- /script/inpaint_mask.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | 3 | import cv2 4 | import numpy as np 5 | 6 | 7 | def create_mask( 8 | img: np.ndarray, 9 | dilate_kernal_size: int, 10 | x_offset: int = -2, 11 | y_offset: int = -2, 12 | binary: bool = True, 13 | ) -> np.ndarray: 14 | _, width = img.shape[:2] 15 | 16 | # Guardian Line 17 | src_mirrored = cv2.flip(img, 1) 18 | combined = np.hstack((img, src_mirrored)) 19 | colors = [[250, 250, 250], [5, 5, 5]] if binary else [] 20 | add_text_to_image(combined, width, colors) 21 | 22 | # 形态学掩码 23 | mask_morph, black_font = create_mask_morph( 24 | combined, dilate_kernal_size, x_offset, y_offset 25 | ) 26 | if black_font: 27 | combined = cv2.bitwise_not(combined) 28 | img_masked = cv2.bitwise_and(combined, combined, mask=mask_morph) 29 | 30 | # 色彩空间掩码 31 | mask_color = create_mask_color(img_masked, dilate_kernal_size, x_offset, y_offset) 32 | result_mask = cv2.bitwise_and(mask_morph, mask_morph, mask=mask_color) 33 | result_mask = result_mask[:, :width] 34 | 35 | return result_mask 36 | 37 | 38 | def create_mask_morph( 39 | img: np.ndarray, 40 | dilate_kernal_size: int, 41 | x_offset: int = -2, 42 | y_offset: int = -2, 43 | ) -> Tuple[np.ndarray, bool]: 44 | gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY) 45 | 46 | # 使用高斯模糊减少噪声 47 | blurred = cv2.GaussianBlur(gray, (3, 3), 0) 48 | 49 | # 使用Sobel算子进行梯度检测 50 | grad_x = cv2.Sobel(blurred, cv2.CV_64F, 1, 0, ksize=3) 51 | grad_y = cv2.Sobel(blurred, cv2.CV_64F, 0, 1, ksize=3) 52 | gradient = cv2.addWeighted( 53 | cv2.convertScaleAbs(grad_x), 0.5, cv2.convertScaleAbs(grad_y), 0.5, 0 54 | ) 55 | 56 | # 使用Otsu阈值进行二值化 57 | _, binary = cv2.threshold(gradient, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 58 | 59 | # 使用闭操作去除噪声, 使用开操作去除细小噪声 60 | kernel = np.ones((2, 2), np.uint8) 61 | morph = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, kernel) 62 | morph = cv2.morphologyEx(morph, cv2.MORPH_OPEN, kernel) 63 | 64 | # 查找轮廓 65 | contours, _ = cv2.findContours(morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 66 | mask = np.zeros_like(binary) 67 | cv2.drawContours(mask, contours, -1, 255, thickness=cv2.FILLED) 68 | 69 | kernel = np.ones((dilate_kernal_size, dilate_kernal_size), np.uint8) 70 | mask = cv2.dilate(mask, kernel, iterations=1) 71 | mask = move_mask(mask, x_offset=x_offset, y_offset=y_offset) 72 | 73 | # 判断文字黑白 74 | if mask.any(): 75 | pixel_mean = np.sum(gray * (mask > 0)) / np.count_nonzero(mask) 76 | gray_mean = np.mean(gray) 77 | black_font = pixel_mean < gray_mean 78 | else: 79 | black_font = False 80 | return mask, black_font 81 | 82 | 83 | def create_mask_color( 84 | img: np.ndarray, 85 | dilate_kernal_size: int, 86 | x_offset: int = -2, 87 | y_offset: int = -2, 88 | ) -> np.ndarray: 89 | # 转换到 HSV 颜色空间 90 | hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) 91 | _, _, v = cv2.split(hsv) 92 | 93 | # 使用亮度通道进行阈值分割 94 | _, binary_v = cv2.threshold(v, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) 95 | 96 | # 使用形态学操作去除线条和噪声 97 | kernel = np.ones((2, 2), np.uint8) 98 | morph = cv2.morphologyEx(binary_v, cv2.MORPH_CLOSE, kernel) 99 | morph = cv2.morphologyEx(morph, cv2.MORPH_OPEN, kernel) 100 | 101 | # 查找轮廓 102 | contours, _ = cv2.findContours(morph, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) 103 | mask = np.zeros_like(v) 104 | cv2.drawContours(mask, contours, -1, 255, thickness=cv2.FILLED) 105 | 106 | kernel = np.ones((dilate_kernal_size + 10, dilate_kernal_size + 10), np.uint8) 107 | mask = cv2.dilate(mask, kernel, iterations=1) 108 | mask = move_mask(mask, x_offset=x_offset, y_offset=y_offset) 109 | return mask 110 | 111 | 112 | def move_mask( 113 | mask: np.ndarray, 114 | x_offset: int = -2, 115 | y_offset: int = -2, 116 | ) -> np.ndarray: 117 | # 偏移结果 118 | shifted_mask = np.zeros_like(mask) 119 | height, width = mask.shape 120 | 121 | if x_offset >= 0 and y_offset >= 0: # 右下偏移 122 | shifted_mask[y_offset:, x_offset:] = mask[ 123 | : height - y_offset, : width - x_offset 124 | ] 125 | elif x_offset >= 0 and y_offset < 0: # 右上偏移 126 | shifted_mask[: height + y_offset, x_offset:] = mask[ 127 | -y_offset:, : width - x_offset 128 | ] 129 | elif x_offset < 0 and y_offset >= 0: # 左下偏移 130 | shifted_mask[y_offset:, : width + x_offset] = mask[ 131 | : height - y_offset, -x_offset: 132 | ] 133 | else: # 左上偏移 134 | shifted_mask[: height + y_offset, : width + x_offset] = mask[ 135 | -y_offset:, -x_offset: 136 | ] 137 | return shifted_mask 138 | 139 | 140 | def shift_expand_mask( 141 | mask: np.ndarray, up: int = 0, down: int = 0, right: int = 0, left: int = 0 142 | ) -> np.ndarray: 143 | if up + down + right + left == 0: 144 | return mask 145 | 146 | height, width = mask.shape[:2] 147 | expanded_mask = np.zeros_like(mask) 148 | mask_indices = np.argwhere(mask > 0) 149 | for row, col in mask_indices: 150 | start_row = max(row - up, 0) 151 | end_row = min(row + down + 1, height) 152 | start_col = max(col - left, 0) 153 | end_col = min(col + right + 1, width) 154 | expanded_mask[start_row:end_row, start_col:end_col] = 255 155 | 156 | return expanded_mask 157 | 158 | 159 | def add_text_to_image(image: np.ndarray, width: int, colors) -> None: 160 | # fmt: off 161 | text = "ABCDEFGHIJKLM" 162 | char_width = 30 163 | for i, char in enumerate(text, 1): 164 | for ii, color in enumerate(colors, 1): 165 | position = (width + i * char_width, char_width * ii) 166 | cv2.putText(image, char, position, cv2.FONT_HERSHEY_SIMPLEX, \ 167 | 1, color, 10, cv2.LINE_AA) 168 | # fmt: on 169 | -------------------------------------------------------------------------------- /script/inpaint_text.py: -------------------------------------------------------------------------------- 1 | import time 2 | 3 | import cv2 4 | import inpaint.fsr_parallel as fsr_parallel 5 | import inpaint_mask as maskutil 6 | import numpy as np 7 | 8 | try: 9 | import importlib.metadata 10 | 11 | torch_version = importlib.metadata.version("torch") 12 | print(f"torch 已安装,版本: {torch_version}") 13 | import inpaint.lama as lama 14 | 15 | simplelama = lama.SimpleLama() 16 | except ImportError as e: 17 | simplelama = None 18 | 19 | 20 | class Inpainter: 21 | def __init__( 22 | self, 23 | method="INPAINT_NS", 24 | stroke: int = 0, 25 | x_offset: int = -2, 26 | y_offset: int = -2, 27 | autosub: int = 2000, 28 | ) -> None: 29 | if simplelama: 30 | self.lama = simplelama 31 | 32 | self.method = method 33 | self.stroke = stroke 34 | self.dilate_kernal_size = self.stroke * 2 + 1 35 | # 掩码偏移 36 | self.x_offset = x_offset # 向右偏移的像素数 37 | self.y_offset = y_offset # 向下偏移的像素数 38 | # 打轴 39 | self.autosub = autosub 40 | 41 | def create_mask(self, img, binary): 42 | return maskutil.create_mask( 43 | img, 44 | self.dilate_kernal_size, 45 | self.x_offset, 46 | self.y_offset, 47 | binary, 48 | ) 49 | 50 | def inpaint_text(self, img, binary=True): 51 | """识别文字区域并修复 52 | 53 | Args: 54 | img: 输入图像 55 | binary: True白色或黑色文字 False灰色文字 56 | 57 | Returns: 58 | 已修复图像 59 | """ 60 | src = img.copy() 61 | 62 | # 扩展边缘防止绿边 63 | h, w = src.shape[:2] 64 | src = cv2.copyMakeBorder(src, 10, 10, 10, 10, cv2.BORDER_REFLECT) 65 | 66 | mask = self.create_mask(src, binary) 67 | 68 | s = time.time() 69 | 70 | # 图像修复 71 | if self.method == "MASK" or self.method == "AUTOSUB": 72 | image = cv2.cvtColor(src, cv2.COLOR_BGR2BGRA) 73 | overlay = np.zeros_like(image, dtype=np.uint8) 74 | overlay[mask != 0] = [0, 0, 255, 150] # 红色 (RGBA) 75 | overlay[mask == 0, 3] = 0 76 | alpha_channel = overlay[:, :, 3] / 255.0 77 | alpha_inv = 1.0 - alpha_channel 78 | 79 | for c in range(0, 3): 80 | image[:, :, c] = ( 81 | alpha_channel * overlay[:, :, c] + alpha_inv * image[:, :, c] 82 | ) 83 | inpaintImg = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR) 84 | 85 | elif self.method == "INPAINT_NS": 86 | inpaintImg = cv2.inpaint(src, mask, 3, cv2.INPAINT_NS) 87 | 88 | elif self.method == "INPAINT_TELEA": 89 | inpaintImg = cv2.inpaint(src, mask, 3, cv2.INPAINT_TELEA) 90 | 91 | elif self.method == "INPAINT_FSR_FAST": 92 | mask1 = cv2.bitwise_not(mask) 93 | distort = cv2.bitwise_and(src, src, mask=mask1) 94 | inpaintImg = src.copy() 95 | cv2.xphoto.inpaint(distort, mask1, inpaintImg, cv2.xphoto.INPAINT_FSR_FAST) 96 | 97 | elif self.method == "INPAINT_FSR_BEST": 98 | mask1 = cv2.bitwise_not(mask) 99 | distort = cv2.bitwise_and(src, src, mask=mask1) 100 | inpaintImg = src.copy() 101 | cv2.xphoto.inpaint(distort, mask1, inpaintImg, cv2.xphoto.INPAINT_FSR_BEST) 102 | 103 | elif self.method == "INPAINT_FSR_PARA": 104 | inpaintImg = fsr_parallel.fsr(src, mask) 105 | 106 | elif self.method == "INPAINT_LAMA": 107 | inpaintImg = self.lama(src, mask) 108 | 109 | e = time.time() 110 | print("inpaint time:", e - s) # inpaint time 111 | return inpaintImg[10 : h + 10, 10 : w + 10], mask[10 : h + 10, 10 : w + 10] 112 | -------------------------------------------------------------------------------- /script/inpaint_video.py: -------------------------------------------------------------------------------- 1 | import queue 2 | import subprocess 3 | import threading 4 | import time 5 | from collections import deque 6 | from pathlib import Path 7 | from typing import Callable, List, Tuple 8 | 9 | import cv2 10 | import inpaint_mask as maskutils 11 | import numpy as np 12 | from inpaint_text import Inpainter 13 | 14 | 15 | class VideoInpainter: 16 | QUEUE_SIZE = 15 17 | AUTOSUB_INTERVAL_FRAME = 10 18 | 19 | def __init__( 20 | self, 21 | path: str, 22 | regions: List[Tuple[int, int, int, int]], 23 | time_table: List[List[str]], 24 | inpainter: Inpainter, 25 | progress_callback: Callable[[float], None], 26 | input_frame_callback: Callable[[np.ndarray], None], 27 | output_frame_callback: Callable[[np.ndarray], None], 28 | update_table_callback: Callable[[int, int, str], None], 29 | stop_check: Callable[[], bool], 30 | ): 31 | self.inpainter = inpainter 32 | self.regions = regions 33 | self.time_table = time_table 34 | 35 | self.path = Path(path) 36 | self.cap: cv2.VideoCapture | None = None # input_video 37 | self.out: cv2.VideoWriter | None = None # output_video 38 | self.total_frame_count = 0 39 | 40 | self.read_queue: queue.Queue = queue.Queue(maxsize=self.QUEUE_SIZE) 41 | self.process_queue: queue.Queue = queue.Queue(maxsize=self.QUEUE_SIZE) 42 | 43 | self.progress_callback = progress_callback 44 | self.input_frame_callback = input_frame_callback 45 | self.output_frame_callback = output_frame_callback 46 | self.update_table_callback = update_table_callback 47 | self.stop_check = stop_check 48 | 49 | self._is_cancel = False 50 | self.cache = [None for _ in self.regions] 51 | self.last_frame = [deque([None] * 5, maxlen=5) for _ in self.regions] 52 | 53 | # autosub 54 | self.AUTO_last_sentence_id = 0 55 | self.AUTO_last_sentence_time = int(self.AUTOSUB_INTERVAL_FRAME) 56 | self.AUTO_subtitle_active = False 57 | self.AUTO_timeline = [] 58 | self.AUTO_last_region_start = 0 59 | self.AUTO_last_frame_start = 0 60 | 61 | def run(self) -> bool: 62 | if self.inpainter.method == "AUTOSUB" and len(self.regions) != 1: 63 | print(f"Autosub only accepts ONE region!") 64 | return {"status": "Error", "message": "自动打轴只接受单个选区!"} 65 | try: 66 | self._is_cancel = False 67 | self.read_queue: queue.Queue = queue.Queue(maxsize=self.QUEUE_SIZE) 68 | self.process_queue: queue.Queue = queue.Queue(maxsize=self.QUEUE_SIZE) 69 | self.cache = [None for _ in self.regions] 70 | self.last_frame = [deque([None] * 5, maxlen=5) for _ in self.regions] 71 | self.AUTO_last_sentence_id = 0 72 | self.AUTO_last_sentence_time = int(self.AUTOSUB_INTERVAL_FRAME) 73 | self.AUTO_subtitle_active = False 74 | self.AUTO_timeline = [] 75 | self.AUTO_last_region_start = 0 76 | self.AUTO_last_frame_start = 0 77 | 78 | self.cap = cv2.VideoCapture(str(self.path)) 79 | self.fps = self.cap.get(cv2.CAP_PROP_FPS) 80 | width = int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)) 81 | height = int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) 82 | fourcc = cv2.VideoWriter_fourcc(*"mp4v") 83 | 84 | # path/file.mp4 - > path/file_temp.mp4 85 | output_path = self.path.with_name(self.path.stem + "_temp.mp4") 86 | self.out = cv2.VideoWriter( 87 | str(output_path), fourcc, self.fps, (width, height) 88 | ) 89 | self.total_frame_count = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) 90 | 91 | # 生产者-消费者模式实现并发 92 | threads = [ 93 | threading.Thread(target=self.video_reader, name="ReaderThread"), 94 | threading.Thread(target=self.video_processor, name="ProcessorThread"), 95 | threading.Thread(target=self.video_writer, name="WriterThread"), 96 | ] 97 | 98 | for thread in threads: 99 | thread.start() 100 | 101 | for thread in threads: 102 | thread.join() 103 | 104 | self.progress_callback(100) 105 | 106 | except Exception as e: 107 | print(f"An error occurred during video processing: {str(e)}") 108 | return {"status": "Warn", "message": ""} 109 | 110 | finally: 111 | # Release resources 112 | if self.cap: 113 | self.cap.release() 114 | if self.out: 115 | self.out.release() 116 | 117 | if not self._is_cancel: 118 | if self.inpainter.method == "AUTOSUB": 119 | self.export_subtitle() 120 | else: 121 | self.combine_audio() 122 | output_path.unlink() 123 | return {"status": "Success", "message": ""} 124 | else: 125 | output_path.unlink() 126 | return {"status": "Warn", "message": ""} 127 | 128 | def video_reader(self) -> None: 129 | frame_idx = 0 130 | while self.cap and self.cap.isOpened() and not self.stop_check(): 131 | ret, frame = self.cap.read() 132 | if not ret: 133 | break 134 | frames = (frame_idx, frame) 135 | while True: 136 | if self.stop_check(): 137 | self._is_cancel = True 138 | print("Reader thread cancelled while queue is full!") 139 | return 140 | try: 141 | self.read_queue.put(frames, timeout=0.1) 142 | frame_idx += 1 143 | break 144 | except queue.Full: 145 | time.sleep(0.01) # 短暂睡眠以避免CPU过度使用 146 | 147 | # 发送结束信号 148 | while True: 149 | if self.stop_check(): 150 | self._is_cancel = True 151 | print("Reader thread cancelled while trying to send end signal") 152 | return 153 | try: 154 | self.read_queue.put(None, timeout=0.1) 155 | break 156 | except queue.Full: 157 | time.sleep(0.01) 158 | 159 | def video_processor(self) -> None: 160 | while not self.stop_check(): 161 | try: 162 | frames = self.read_queue.get(timeout=0.1) 163 | if frames is None: 164 | break 165 | frame_idx, frame = frames 166 | try: 167 | processed_frame = self.frame_processor(frame_idx, frame) 168 | while True: 169 | if self.stop_check(): 170 | self._is_cancel = True 171 | print("Processor thread cancelled while queue is full!") 172 | return 173 | try: 174 | self.process_queue.put( 175 | (frame_idx, processed_frame), timeout=0.1 176 | ) 177 | break 178 | except queue.Full: 179 | time.sleep(0.01) 180 | except Exception as e: 181 | print(f"Error processing frame {frame_idx}: {str(e)}") 182 | finally: 183 | self.read_queue.task_done() 184 | except queue.Empty: 185 | continue 186 | 187 | # 发送结束信号 188 | while True: 189 | if self.stop_check(): 190 | self._is_cancel = True 191 | print("Processor thread cancelled while trying to send end signal") 192 | return 193 | try: 194 | self.process_queue.put(None, timeout=0.1) 195 | break 196 | except queue.Full: 197 | time.sleep(0.01) 198 | 199 | def video_writer(self) -> None: 200 | written_count = 0 201 | while not self.stop_check(): 202 | try: 203 | frames = self.process_queue.get(timeout=0.1) 204 | if frames is None: 205 | break 206 | _, frame = frames 207 | self.out.write(frame) 208 | written_count += 1 209 | progress = (written_count / self.total_frame_count) * 100 210 | self.progress_callback(progress) 211 | self.process_queue.task_done() 212 | except queue.Empty: 213 | continue 214 | 215 | def combine_audio(self) -> None: 216 | """Extract audio from the original video and combine it with the processed video""" 217 | # path/file.mp4 - > path/file_temp.mp4 218 | temp_path = self.path.with_name(self.path.stem + "_temp.mp4") 219 | # path/file.mp4 -> path/file_output.mp4 220 | output_path = self.path.with_name(self.path.stem + "_output.mp4") 221 | 222 | ffmpeg_path = Path(__file__).parent.parent / "ffmpeg.exe" 223 | # fmt: off 224 | command = [ 225 | str(ffmpeg_path), 226 | "-y", 227 | "-i", str(temp_path), 228 | "-i", str(self.path), 229 | "-map", "0:v", "-map", "1:a", "-c", "copy", 230 | str(output_path), 231 | ] 232 | # fmt: on 233 | try: 234 | subprocess.run(command, capture_output=True, text=True, encoding="utf-8") 235 | except subprocess.CalledProcessError as e: 236 | print(f"Error combining audio: {e.stderr}") 237 | raise 238 | 239 | def frame_processor(self, frame_idx: int, frame: np.ndarray) -> np.ndarray: 240 | if self.inpainter.method.startswith("INPAINT_FSR"): 241 | return self.frame_processor_with_cache(frame_idx, frame) 242 | elif self.inpainter.method == "AUTOSUB": 243 | return self.frame_processor_autosubtitle(frame_idx, frame) 244 | else: 245 | return self.frame_processor_no_cache(frame_idx, frame) 246 | 247 | def frame_processor_no_cache(self, frame_idx: int, frame: np.ndarray) -> np.ndarray: 248 | frame_before = frame.copy() 249 | frame_after = frame.copy() 250 | 251 | flag = True 252 | for region_id, region in enumerate(self.regions): 253 | if self.time_table[region_id][frame_idx]: 254 | x1, x2, y1, y2 = region["region"] 255 | frame_area = frame_after[y1:y2, x1:x2] 256 | 257 | if frame_area.size == 0: # 空选区跳过 258 | continue 259 | 260 | flag = False 261 | frame_area_inpainted, _ = self.inpainter.inpaint_text( 262 | frame_area, region["binary"] 263 | ) 264 | frame_after[y1:y2, x1:x2] = frame_area_inpainted 265 | self.update_table_callback(region_id, frame_idx, "") 266 | 267 | if flag: 268 | self.update_table_callback(-1, frame_idx, "") 269 | # Callbacks handling 270 | if frame_idx % 10 == 0: 271 | self.input_frame_callback(frame_before) 272 | self.output_frame_callback(frame_after) 273 | print(frame_idx) 274 | 275 | return frame_after 276 | 277 | def frame_processor_with_cache( 278 | self, frame_idx: int, frame: np.ndarray 279 | ) -> np.ndarray: 280 | frame_before = frame.copy() 281 | frame_after = frame.copy() 282 | 283 | flag = True 284 | for region_id, region in enumerate(self.regions): 285 | if self.time_table[region_id][frame_idx]: 286 | x1, x2, y1, y2 = region["region"] 287 | frame_copy = frame_after[y1:y2, x1:x2].copy() 288 | 289 | if frame_copy.size == 0: # 空选区跳过 290 | continue 291 | 292 | flag = False 293 | # 检查缓存 294 | frame_gray = cv2.cvtColor(frame_copy, cv2.COLOR_BGR2GRAY) 295 | same_with_last = self.check_same_frame_with_last(region_id, frame_gray) 296 | cache, similarity = self.check_cache_item(region_id, frame_copy) 297 | 298 | if similarity > 0.80: 299 | frame_area_inpainted = cache 300 | elif similarity > 0.65 and not same_with_last: 301 | frame_area_inpainted = cache 302 | else: 303 | frame_area_inpainted, _ = self.inpainter.inpaint_text( 304 | frame_copy, region["binary"] 305 | ) 306 | # 保存到缓存队列中 307 | self.cache[region_id] = {"inpainted": frame_area_inpainted.copy()} 308 | 309 | frame_after[y1:y2, x1:x2] = frame_area_inpainted 310 | self.update_table_callback(region_id, frame_idx, "") 311 | 312 | if flag: 313 | self.update_table_callback(-1, frame_idx, "") 314 | # Callbacks handling 315 | self.input_frame_callback(frame_before) 316 | self.output_frame_callback(frame_after) 317 | print(frame_idx) 318 | 319 | return frame_after 320 | 321 | def check_same_frame_with_last(self, region_id, frame_gray): 322 | """ 323 | 通过 SSIM 检查当前帧相比上一帧内容相似度 324 | 如果完全一样,判断是当前句子播放完的等待时间,强制刷新修复 325 | 326 | 参数: 327 | - self.last_frame[region_id] (np.array): 上一帧的图像 328 | - frame_gray (np.array): 当前帧的图像 329 | 330 | 返回: 331 | - bool: 返回一个布尔值,表示是否被认为是相同的 332 | """ 333 | frame1 = self.last_frame[region_id].pop() 334 | frame5 = self.last_frame[region_id].popleft() 335 | self.last_frame[region_id].append(frame1) 336 | self.last_frame[region_id].append(frame_gray.copy()) 337 | if frame1 is None or frame5 is None: 338 | return False 339 | score, _ = cv2.quality.QualitySSIM_compute(frame1, frame_gray) 340 | score_, _ = cv2.quality.QualitySSIM_compute(frame1, frame5) 341 | return score[0] > 0.99 and score_[0] < 0.99 342 | 343 | def check_cache_item(self, region_id, frame_copy): 344 | # 检查缓存 345 | best_cache_item = None 346 | sim = -1 347 | 348 | cache_item = self.cache[region_id] 349 | if cache_item is None: 350 | return best_cache_item, sim 351 | similarity = self.calculate_frame_similarity( 352 | frame_copy, cache_item["inpainted"], self.regions[region_id]["binary"] 353 | ) 354 | # 找到 similarity > 0.995 的项 355 | if similarity > 0.7: 356 | best_cache_item = cache_item["inpainted"] 357 | sim = similarity 358 | 359 | return best_cache_item, sim 360 | 361 | def calculate_frame_similarity( 362 | self, frame1: np.ndarray, frame2: np.ndarray, binary: bool 363 | ) -> float: 364 | mask = self.inpainter.create_mask(frame1, binary) 365 | 366 | # # 横向整行扩展掩码 367 | # extended_mask = np.zeros_like(mask, dtype=np.uint8) 368 | # row_mask = np.any(mask > 0, axis=1).astype(np.uint8) * 255 369 | # extended_mask[row_mask > 0, :] = 255 370 | 371 | # 横向膨胀扩展掩码 372 | # kernel = np.ones((1, 25), np.uint8) 373 | # extended_mask = cv2.dilate(mask, kernel, iterations=1) 374 | 375 | # 横向最右扩展掩码 376 | mask = maskutils.shift_expand_mask(mask, right=50) 377 | 378 | # 掩码反向 379 | mask1 = cv2.bitwise_not(mask) 380 | 381 | # SSIM 相似度 382 | frame1_minus_mask = cv2.bitwise_and(frame1, frame1, mask=mask1) 383 | frame2_minus_mask = cv2.bitwise_and(frame2, frame2, mask=mask1) 384 | 385 | gray1 = cv2.cvtColor(frame1_minus_mask, cv2.COLOR_BGR2GRAY) 386 | gray2 = cv2.cvtColor(frame2_minus_mask, cv2.COLOR_BGR2GRAY) 387 | score, _ = cv2.quality.QualitySSIM_compute(gray1, gray2) 388 | 389 | # 亮度权重 390 | gray_diff = abs(np.mean(gray1) - np.mean(gray2)) 391 | gray_weight = (gray_diff / 255) ** 0.15 392 | 393 | return score[0] * (1 - gray_weight) 394 | 395 | """ 396 | 自动打轴相关 397 | """ 398 | 399 | def frame_processor_autosubtitle( 400 | self, frame_idx: int, frame: np.ndarray 401 | ) -> np.ndarray: 402 | frame_before = frame.copy() 403 | frame_after = frame.copy() 404 | 405 | flag = True 406 | for region_id, region in enumerate(self.regions): 407 | x1, x2, y1, y2 = region["region"] 408 | frame_area = frame_after[y1:y2, x1:x2] 409 | if frame_area.size == 0: # 空选区跳过 410 | continue 411 | 412 | # 处理第一帧 413 | if self.cache[region_id] is None: 414 | self.cache[region_id] = self.inpainter.create_mask( 415 | frame_area, region["binary"] 416 | ) 417 | self.update_table_callback( 418 | self.AUTO_last_sentence_id, 419 | frame_idx, 420 | "-0 +0", 421 | ) 422 | continue 423 | 424 | flag = False 425 | ret = self.check_same_sentence_with_last( 426 | region_id, frame_area, self.inpainter.autosub 427 | ) 428 | same_frame, area_increase, area_decrease = ret 429 | 430 | # 判断新的一行 431 | if ( 432 | not same_frame 433 | and self.AUTO_last_sentence_time >= self.AUTOSUB_INTERVAL_FRAME 434 | ): 435 | self.AUTO_timeline.append( 436 | { 437 | "id": self.AUTO_last_sentence_id, 438 | "start": self.AUTO_last_region_start, 439 | "end": frame_idx - 1, 440 | } 441 | ) 442 | self.AUTO_last_sentence_id += 1 443 | self.AUTO_last_sentence_time = 0 444 | self.AUTO_last_region_start = frame_idx 445 | self.AUTO_last_frame_start = area_increase 446 | 447 | # 更新当前帧 448 | self.update_table_callback( 449 | self.AUTO_last_sentence_id, 450 | frame_idx, 451 | f"-{area_decrease} +{area_increase}", 452 | ) 453 | self.AUTO_last_sentence_time += 1 454 | 455 | # 处理最后一帧 456 | if frame_idx == self.total_frame_count - 1: 457 | self.AUTO_timeline.append( 458 | { 459 | "id": self.AUTO_last_sentence_id, 460 | "start": self.AUTO_last_region_start, 461 | "end": frame_idx, 462 | } 463 | ) 464 | 465 | if flag: 466 | self.update_table_callback(-1, frame_idx, "") 467 | # Callbacks handling 468 | if frame_idx % 10 == 0: 469 | self.input_frame_callback(frame_before) 470 | self.output_frame_callback(frame_after) 471 | print(frame_idx) 472 | return frame_after 473 | 474 | def check_same_sentence_with_last( 475 | self, region_id, frame_copy, noise_threshold=2000 476 | ): 477 | """ 478 | 通过掩码检查当前帧和上一帧属于同一句子 479 | 480 | 参数: 481 | - self.cache[region_id] (np.array): 上一帧的掩码 482 | - frame_copy (np.array): 当前帧的图像 483 | - noise_threshold (int): 允许的噪声阈值,默认为2000。 484 | 485 | 返回: 486 | - (bool, int, int): 返回一个布尔值,表示掩码是否被认为是相同的; 487 | 以及增加的区域像素计数,减少的区域像素计数。 488 | """ 489 | mask_cur = self.inpainter.create_mask( 490 | frame_copy, self.regions[region_id]["binary"] 491 | ) 492 | 493 | mask_last = self.cache[region_id] 494 | self.cache[region_id] = mask_cur 495 | 496 | mask_cur_binary = mask_cur > 0 497 | mask_last_binary = mask_last > 0 498 | 499 | if np.sum(mask_cur) == 0 and np.sum(mask_last) > 0: 500 | return False, 0, np.sum(mask_last) 501 | 502 | # 判断是否第二张掩码包含了第一张掩码 503 | if np.all(mask_last_binary[mask_cur_binary]): 504 | if np.sum(mask_last) == 0 and np.sum(mask_cur) > 0: 505 | return False, np.sum(mask_cur), 0 506 | return True, 0, 0 507 | 508 | added_region = ~mask_last_binary & mask_cur_binary 509 | added_region_count = np.sum(added_region) 510 | minused_region = ~mask_cur_binary & mask_last_binary 511 | minused_region_count = np.sum(minused_region) 512 | 513 | # 如果变化部分超过阈值,则认为是新的句子 514 | if (added_region_count >= noise_threshold) or ( 515 | minused_region_count >= noise_threshold 516 | ): 517 | return False, added_region_count, minused_region_count 518 | 519 | return True, added_region_count, minused_region_count 520 | 521 | def format_time(self, second): 522 | """ 523 | s -> h:mm:ss.ss 524 | """ 525 | H = second // 3600 526 | M = (second - H * 3600) // 60 527 | S = second - H * 3600 - M * 60 + 0.01 528 | format_time = "%d:%02d:%05.2f" % (H, M, S) 529 | return format_time 530 | 531 | def export_subtitle(self): 532 | TEMPLATE = """\ 533 | [Script Info] 534 | ; Script generated by Aegisub 3.2.2 535 | ; http://www.aegisub.org/ 536 | Title: GAKUEN IDOLMASTER 537 | ScriptType: v4.00+ 538 | WrapStyle: 0 539 | YCbCr Matrix: 540 | PlayResX: 541 | PlayResY: 542 | 543 | [Aegisub Project Garbage] 544 | Last Style Storage: Default 545 | Audio File: 546 | Video File: 547 | Video AR Mode: 548 | Video AR Value: 549 | Video Zoom Percent: 550 | Scroll Position: 551 | Active Line: 552 | Video Position: 553 | 554 | [V4+ Styles] 555 | Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, Alignment, MarginL, MarginR, MarginV, Encoding 556 | Style: Deault,思源黑体 Medium,59,&H00212121,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,3,0,1,0,0,7,295,8,1420,1 557 | 558 | [Events] 559 | Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 560 | """ 561 | # 导出时轴 562 | last_end = None 563 | for timeline in self.AUTO_timeline: 564 | start_frame, end_frame = timeline["start"], timeline["end"] 565 | if last_end and start_frame - 1 == last_end: 566 | start_time = self.format_time(last_end / self.fps) 567 | else: 568 | start_time = self.format_time(start_frame / self.fps) 569 | end_time = self.format_time(end_frame / self.fps) 570 | TEMPLATE += f"Dialogue: 0,{start_time},{end_time},Deault,,0,0,0,,\n" 571 | last_end = end_frame 572 | 573 | with open(f"{self.path}.ass", "w", encoding="utf-8") as f: 574 | f.write(TEMPLATE) 575 | -------------------------------------------------------------------------------- /script/main_ui.py: -------------------------------------------------------------------------------- 1 | import re 2 | import time 3 | import json 4 | import sys 5 | from pathlib import Path 6 | 7 | import cv2 8 | import numpy as np 9 | # fmt: off 10 | from PyQt5.QtWidgets import ( 11 | QGridLayout, QHBoxLayout, QVBoxLayout, QSpacerItem, 12 | QApplication, QMainWindow, QWidget, QSplashScreen, 13 | QLabel, QComboBox, QSpinBox, QMessageBox, QPushButton, QSlider, 14 | QFileDialog, QDialog, QProgressDialog, QDialogButtonBox, 15 | QTableWidget, QTableWidgetItem, 16 | QSizePolicy, QAction 17 | ) 18 | # fmt: on 19 | from PyQt5.QtCore import Qt, QPoint, QRect, QThread, pyqtSignal 20 | from PyQt5.QtGui import QFont, QPixmap, QIcon, QImage, QColor, QPainter, QPen 21 | 22 | from inpaint_text import Inpainter 23 | from inpaint_video import VideoInpainter 24 | 25 | # fmt: off 26 | try: 27 | import importlib.metadata 28 | importlib.metadata.version("torch") 29 | lama_flag = True 30 | except ImportError as e: 31 | lama_flag = False 32 | # fmt: on 33 | 34 | 35 | class InfoWindow(QMessageBox): 36 | def __init__(self, msg, parent=None): 37 | super().__init__(parent) 38 | self.setWindowTitle("Information") 39 | self.setIcon(QMessageBox.Information) 40 | self.setText(msg) 41 | self.setStandardButtons(QMessageBox.Ok) 42 | self.exec_() 43 | 44 | 45 | class WarnWindow(QMessageBox): 46 | def __init__(self, msg, parent=None): 47 | super().__init__(parent) 48 | self.setWindowTitle("Warning") 49 | self.setIcon(QMessageBox.Warning) 50 | self.setText(msg) 51 | self.setStandardButtons(QMessageBox.Ok) 52 | self.exec_() 53 | 54 | 55 | class ErrorWindow(QMessageBox): 56 | def __init__(self, msg, parent=None): 57 | super().__init__(parent) 58 | self.setWindowTitle("Error") 59 | self.setIcon(QMessageBox.Critical) 60 | self.setText(msg) 61 | self.setStandardButtons(QMessageBox.Ok) 62 | self.exec_() 63 | 64 | 65 | class ParameterWindow(QDialog): 66 | def __init__( 67 | self, 68 | stroke_input, 69 | x_offset_input=-2, 70 | y_offset_input=-2, 71 | autosub=3000, 72 | parent=None, 73 | ): 74 | super().__init__(parent) 75 | self.setWindowTitle("参数设置") 76 | 77 | # 创建一个网格布局 78 | layout = QVBoxLayout(self) 79 | grid_layout = QGridLayout() 80 | 81 | # 描边 82 | self.stroke_label = QLabel("描边:") 83 | self.stroke_input = QSpinBox(self) 84 | self.stroke_input.setRange(0, 100) 85 | self.stroke_input.setValue(stroke_input) 86 | self.stroke_input.setAlignment(Qt.AlignCenter) 87 | grid_layout.addWidget(self.stroke_label, 0, 0) 88 | grid_layout.addWidget(self.stroke_input, 0, 1, 1, 3) 89 | 90 | # 水平偏移 91 | self.x_offset_label = QLabel("水平偏移:") 92 | self.x_offset_input = QSpinBox(self) 93 | self.x_offset_input.setRange(-10, 100) 94 | self.x_offset_input.setValue(x_offset_input) 95 | self.x_offset_input.setAlignment(Qt.AlignCenter) 96 | grid_layout.addWidget(self.x_offset_label, 1, 0) 97 | grid_layout.addWidget(self.x_offset_input, 1, 1) 98 | 99 | # 垂直偏移 100 | self.y_offset_label = QLabel("垂直偏移:") 101 | self.y_offset_input = QSpinBox(self) 102 | self.y_offset_input.setRange(-10, 100) 103 | self.y_offset_input.setValue(y_offset_input) 104 | self.y_offset_input.setAlignment(Qt.AlignCenter) 105 | grid_layout.addWidget(self.y_offset_label, 1, 2) 106 | grid_layout.addWidget(self.y_offset_input, 1, 3) 107 | 108 | # 打轴机阈值 109 | self.autosub_label = QLabel("打轴阈值:") 110 | self.autosub_input = QSpinBox(self) 111 | self.autosub_input.setRange(0, 999999) 112 | self.autosub_input.setValue(autosub) 113 | self.autosub_input.setAlignment(Qt.AlignCenter) 114 | grid_layout.addWidget(self.autosub_label, 2, 0) 115 | grid_layout.addWidget(self.autosub_input, 2, 1, 1, 3) 116 | 117 | # 按钮 118 | button_box = QDialogButtonBox( 119 | QDialogButtonBox.Ok | QDialogButtonBox.Cancel, self 120 | ) 121 | button_box.accepted.connect(self.accept) 122 | button_box.rejected.connect(self.reject) 123 | 124 | # 将网格布局添加到垂直布局中 125 | layout.addLayout(grid_layout) 126 | layout.addWidget(button_box) 127 | 128 | # 返回参数值 129 | def get_values(self): 130 | return ( 131 | self.stroke_input.value(), 132 | self.x_offset_input.value(), 133 | self.y_offset_input.value(), 134 | self.autosub_input.value(), 135 | ) 136 | 137 | 138 | class ProgressWindow(QProgressDialog): 139 | cancel_signal = pyqtSignal() 140 | 141 | def __init__(self, parent=None): 142 | super().__init__("Processing...", "Cancel", 0, 100, parent) 143 | self.setWindowTitle("Running") 144 | self.setWindowModality(Qt.WindowModal) 145 | self.setMinimumDuration(0) 146 | self.setValue(0) 147 | self.setAutoClose(True) 148 | 149 | self.start_time = time.time() # Start timing 150 | self._is_canceled = False 151 | self.canceled.connect(self.on_cancel) 152 | 153 | self.show() 154 | 155 | def on_cancel(self): 156 | self._is_canceled = True 157 | self.cancel_signal.emit() 158 | self.close() 159 | 160 | def update_progress(self, value): 161 | if self._is_canceled: 162 | return 163 | self.setValue(value) 164 | if value >= 100 and not self._is_canceled: 165 | self.close() 166 | self.end_time = time.time() # End timing 167 | msg = f"已完成!耗时: {(self.end_time - self.start_time):.2f} 秒" 168 | QMessageBox.information(self, "Complete", msg) 169 | self._is_canceled = True 170 | 171 | 172 | class Worker(QThread): 173 | test_button = pyqtSignal(bool) 174 | time_slider = pyqtSignal(bool) 175 | start_button = pyqtSignal(bool) 176 | update_input_frame = pyqtSignal(np.ndarray) 177 | update_output_frame = pyqtSignal(np.ndarray) 178 | update_progress = pyqtSignal(float) 179 | update_table = pyqtSignal((int, int, str)) 180 | result_signal = pyqtSignal(object) 181 | 182 | def __init__(self, selected_video_path, selected_regions, inpainter, time_table): 183 | super().__init__() 184 | self.selected_video_path = selected_video_path 185 | self.selected_regions = selected_regions 186 | self.inpainter = inpainter 187 | self.time_table = time_table 188 | self._is_running = True 189 | 190 | self.inpaint_video = VideoInpainter( 191 | self.selected_video_path, 192 | self.selected_regions, 193 | self.time_table, 194 | self.inpainter, 195 | self.update_progress.emit, 196 | self.update_input_frame.emit, 197 | self.update_output_frame.emit, 198 | self.update_table.emit, 199 | stop_check=self.stop_check, 200 | ) 201 | 202 | def run(self): 203 | self._is_running = True 204 | self.test_button.emit(False) 205 | self.time_slider.emit(False) 206 | self.start_button.emit(False) 207 | 208 | ret = self.inpaint_video.run() 209 | self.result_signal.emit(ret) 210 | 211 | self.test_button.emit(True) 212 | self.time_slider.emit(True) 213 | self.start_button.emit(True) 214 | 215 | def stop_check(self): 216 | return not self._is_running 217 | 218 | def stop(self): 219 | self._is_running = False 220 | 221 | 222 | class MainWindowLayout(QMainWindow): 223 | """ 224 | 主窗口类,实现组件布局 225 | """ 226 | 227 | def __init__(self): 228 | super().__init__() 229 | self.setWindowTitle("Hare") 230 | self.setAcceptDrops(True) 231 | 232 | # Setup central widget and layout 233 | self.central_widget = QWidget() 234 | self.main_layout = QGridLayout() 235 | self.main_layout.setSpacing(10) 236 | self.central_widget.setLayout(self.main_layout) 237 | self.setCentralWidget(self.central_widget) 238 | 239 | # 加载组件 240 | self.setup_control_toolbar() 241 | self.setup_video_players() 242 | self.setup_subtitle_table() 243 | self.setup_menu_bar() 244 | 245 | # Ensure focus policy 246 | self.setFocusPolicy(Qt.StrongFocus) 247 | 248 | def setup_video_players(self): 249 | """上侧预览视频""" 250 | # 输入视频 251 | self.video_label_input = QLabel(self) 252 | self.video_label_input.setAlignment(Qt.AlignCenter) 253 | self.video_label_input.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) 254 | self.video_label_input.setStyleSheet( 255 | """ 256 | background-color: transparent; 257 | border: 1px solid #76797C; 258 | border-radius: 5px; 259 | """ 260 | ) 261 | self.main_layout.addWidget(self.video_label_input, 1, 0, 3, 10) 262 | 263 | # 输出视频 264 | self.video_label_output = QLabel(self) 265 | self.video_label_output.setAlignment(Qt.AlignCenter) 266 | self.video_label_output.setSizePolicy(QSizePolicy.Ignored, QSizePolicy.Ignored) 267 | self.video_label_output.setStyleSheet( 268 | """ 269 | background-color: transparent; 270 | border: 1px solid #76797C; 271 | border-radius: 5px; 272 | """ 273 | ) 274 | self.main_layout.addWidget(self.video_label_output, 1, 10, 3, 10) 275 | 276 | def setup_subtitle_table(self): 277 | """下侧时轴表""" 278 | self.subtitle_table = QTableWidget() 279 | self.subtitle_table.setColumnCount(101) 280 | self.subtitle_table.setRowCount(5) 281 | self.subtitle_table.setEditTriggers(QTableWidget.NoEditTriggers) 282 | 283 | for i in range(101): 284 | time_label = QTableWidgetItem(f"{i}\n{i//60:02d}:{i%60:02d}.000") 285 | self.subtitle_table.setHorizontalHeaderItem(i, time_label) 286 | 287 | self.main_layout.addWidget(self.subtitle_table, 4, 0, 3, 20) 288 | 289 | def setup_control_toolbar(self): 290 | """上方控制按钮工具栏""" 291 | control_widget = QWidget() 292 | control_widget.setFixedHeight(40) 293 | control_layout = QHBoxLayout(control_widget) 294 | control_layout.setContentsMargins(0, 0, 0, 0) 295 | control_layout.setSpacing(5) 296 | 297 | # 开始按钮 298 | self.start_button = QPushButton("开始运行") 299 | self.start_button.setEnabled(False) 300 | control_layout.addWidget(self.start_button) 301 | 302 | control_layout.addItem( 303 | QSpacerItem(20, 20, QSizePolicy.Minimum, QSizePolicy.Minimum) 304 | ) 305 | 306 | # 时间轴 307 | self.time_label = QLabel("00:00 / 00:00") 308 | self.time_slider = QSlider(Qt.Horizontal) 309 | self.time_slider.setEnabled(False) 310 | control_layout.addWidget(self.time_label) 311 | control_layout.addWidget(self.time_slider) 312 | 313 | control_layout.addItem( 314 | QSpacerItem(20, 20, QSizePolicy.Minimum, QSizePolicy.Minimum) 315 | ) 316 | 317 | # 当前帧测试 相关参数 318 | self.test_button = QPushButton("测试当前帧") 319 | self.test_button.setEnabled(False) 320 | control_layout.addWidget(self.test_button) 321 | 322 | self.algorithm_param_button = QPushButton("参数设置") 323 | control_layout.addWidget(self.algorithm_param_button) 324 | 325 | self.algorithm_label = QLabel("算法选择:") 326 | self.algorithm_combo = QComboBox() 327 | algo_list = [ 328 | "MASK", 329 | "AUTOSUB", 330 | "INPAINT_NS", 331 | # "INPAINT_TELEA", 332 | "INPAINT_FSR_PARA", 333 | # "INPAINT_FSR_FAST", 334 | # "INPAINT_FSR_BEST", 335 | ] 336 | if lama_flag: 337 | algo_list.append("INPAINT_LAMA") 338 | self.algorithm_combo.addItems(algo_list) 339 | control_layout.addWidget(self.algorithm_label) 340 | control_layout.addWidget(self.algorithm_combo) 341 | 342 | self.main_layout.addWidget(control_widget, 0, 0, 1, 20) 343 | 344 | def setup_menu_bar(self): 345 | """顶部菜单栏""" 346 | menu_bar = self.menuBar() 347 | 348 | # File menu 349 | file_menu = menu_bar.addMenu("文件") 350 | self.open_video_button = QAction("选择视频", self) 351 | self.open_sub_button = QAction("选择字幕", self) 352 | file_menu.addAction(self.open_video_button) 353 | file_menu.addAction(self.open_sub_button) 354 | 355 | # Edit menu 356 | edit_menu = menu_bar.addMenu("编辑") 357 | self.load_config_button = QAction("加载配置", self) 358 | self.save_config_button = QAction("保存配置", self) 359 | edit_menu.addAction(self.load_config_button) 360 | edit_menu.addAction(self.save_config_button) 361 | 362 | # Help menu 363 | help_menu = menu_bar.addMenu("帮助") 364 | about_action = QAction("关于", self) 365 | test_action = QAction("测试用选项卡", self) 366 | help_menu.addAction(about_action) 367 | help_menu.addAction(test_action) 368 | 369 | 370 | class MainWindow(MainWindowLayout): 371 | """ 372 | 主窗口类,实现信号槽绑定和具体功能 373 | """ 374 | 375 | def __init__(self): 376 | """ 377 | 初始化主窗口,设置界面各控件的信号槽连接,初始化相关参数 378 | """ 379 | # 初始化布局 380 | super().__init__() 381 | # 连接信号槽 382 | load_video = lambda: self.load_video_file(None) 383 | self.open_video_button.triggered.connect(load_video) # 打开视频文件 384 | load_subtitle = lambda: self.load_subtitle_file(None) 385 | self.open_sub_button.triggered.connect(load_subtitle) # 打开字幕文件 386 | load_config = lambda: self.load_config(None) 387 | self.load_config_button.triggered.connect(load_config) # 打开配置 388 | self.save_config_button.triggered.connect(self.save_config) # 保存配置 389 | self.time_slider.sliderMoved.connect(self.update_frame) # 更新视频帧 390 | self.time_slider.sliderMoved.connect(self.roll_table) # 滚动字幕表格 391 | self.video_label_input.mousePressEvent = self.start_drawing # 开始绘制选区 392 | self.video_label_input.mouseMoveEvent = self.update_drawing # 更新绘制 393 | self.video_label_input.mouseReleaseEvent = self.end_drawing # 结束绘制 394 | self.algorithm_param_button.clicked.connect(self.update_param) # 更新算法参数 395 | self.test_button.clicked.connect(self.test) # 测试图像修复算法 396 | self.start_button.clicked.connect(self.run) # 运行修复任务 397 | self.subtitle_table.itemSelectionChanged.connect(self.selected_cell) 398 | self.subtitle_table.cellDoubleClicked.connect(self.change_state_cell) 399 | self.subtitle_table.verticalHeader().sectionClicked.connect(self.select_region) 400 | self.subtitle_table.verticalHeader().sectionDoubleClicked.connect( 401 | self.change_region_color 402 | ) 403 | 404 | # 初始化视频相关参数 405 | self.cap = None 406 | self.total_frames = 0 407 | self.fps = 0 408 | self.video_frame_size = (0, 0) 409 | self.table = {} # 存储时轴表格信息 410 | self.video_path = "" 411 | self.subtitle_path = "" 412 | 413 | # 初始化选区参数 414 | self.x_offset, self.y_offset = -2, -2 415 | self.start_point = QPoint() 416 | self.end_point = QPoint() 417 | self.selected_regions = [{"region": QRect(0, 0, 0, 0), "binary": True}] 418 | self.is_drawing = False 419 | self.pixmap = None 420 | self.draw_id = 0 421 | 422 | # 初始化算法参数 423 | self.worker_thread = None 424 | self.stroke_input = 0 425 | self.x_offset_input = 0 426 | self.y_offset_input = 0 427 | self.autosub_input = 0 428 | self.inpainter = Inpainter() 429 | if Path("config.json").exists(): 430 | self.load_config(Path("config.json")) 431 | else: 432 | self.load_default_config() 433 | 434 | # 配置参数持久化 435 | def load_config(self, path=None): 436 | if path is None: 437 | options = QFileDialog.Options() 438 | options |= QFileDialog.ReadOnly 439 | path, _ = QFileDialog.getOpenFileName( 440 | self, 441 | "选择配置文件", 442 | "", 443 | "配置文件 (*.json);;所有文件 (*)", 444 | options=options, 445 | ) 446 | 447 | with open(path, "r", encoding="utf-8") as f: 448 | try: 449 | config = json.loads(f.read()) 450 | print(config) 451 | self.stroke_input = config["stroke"] 452 | self.x_offset_input = config["x_offset"] 453 | self.y_offset_input = config["y_offset"] 454 | self.autosub_input = config["autosub"] 455 | self.algorithm_combo.setCurrentText(config["inpaint"]) 456 | self.inpainter = Inpainter( 457 | method=config["inpaint"], 458 | stroke=self.stroke_input, 459 | x_offset=self.x_offset_input, 460 | y_offset=self.y_offset_input, 461 | autosub=self.autosub_input, 462 | ) 463 | except: 464 | WarnWindow("配置文件错误,请删除 config.json") 465 | self.load_default_config() 466 | 467 | def load_default_config(self): 468 | self.stroke_input = 0 469 | self.x_offset_input = -2 470 | self.y_offset_input = -2 471 | self.autosub_input = 3000 472 | self.inpainter = Inpainter( 473 | "MASK", 474 | ) 475 | 476 | def save_config(self): 477 | with open("config.json", "w", encoding="utf-8") as f: 478 | config = { 479 | "inpaint": self.inpainter.method, 480 | "stroke": self.inpainter.stroke, 481 | "x_offset": self.inpainter.x_offset, 482 | "y_offset": self.inpainter.y_offset, 483 | "autosub": self.autosub_input, 484 | } 485 | f.write(json.dumps(config, indent=4, ensure_ascii=False)) 486 | 487 | def update_param(self): 488 | """ 489 | 更新图像修复算法的参数,通过弹窗获取用户输入的参数 490 | """ 491 | window = ParameterWindow( 492 | self.stroke_input, 493 | self.x_offset_input, 494 | self.y_offset_input, 495 | self.autosub_input, 496 | ) 497 | if window.exec_() == QDialog.Accepted: 498 | ( 499 | self.stroke_input, 500 | self.x_offset_input, 501 | self.y_offset_input, 502 | self.autosub_input, 503 | ) = window.get_values() 504 | 505 | # 载入视频文件和时轴文件 506 | def load_video_file(self, file_name=None): 507 | """ 508 | 打开并加载视频文件,初始化相关视频参数并更新界面 509 | """ 510 | if file_name is None: 511 | options = QFileDialog.Options() 512 | options |= QFileDialog.ReadOnly 513 | file_name, _ = QFileDialog.getOpenFileName( 514 | self, 515 | "选择视频文件", 516 | "", 517 | "视频文件 (*.mp4);;所有文件 (*)", 518 | options=options, 519 | ) 520 | if not file_name: 521 | return 522 | 523 | if self.cap is not None and self.cap.isOpened(): 524 | if file_name == self.video_path: 525 | self.init_table() 526 | return 527 | else: 528 | self.cap.release() 529 | 530 | self.cap = cv2.VideoCapture(file_name) 531 | self.video_path = file_name 532 | self.subtitle_path = "" 533 | if not self.cap.isOpened(): 534 | ErrorWindow("无法打开视频文件") 535 | return 536 | 537 | # 初始化视频相关参数 538 | self.total_frames = int(self.cap.get(cv2.CAP_PROP_FRAME_COUNT)) 539 | self.fps = self.cap.get(cv2.CAP_PROP_FPS) 540 | self.video_frame_size = ( 541 | int(self.cap.get(cv2.CAP_PROP_FRAME_WIDTH)), 542 | int(self.cap.get(cv2.CAP_PROP_FRAME_HEIGHT)), 543 | ) 544 | self.time_slider.setRange(0, self.total_frames - 1) 545 | self.draw_id = 0 546 | 547 | # 更新界面 548 | self.init_offset() 549 | self.init_table() 550 | self.update_frame(0) 551 | self.update_time_label(0) 552 | self.start_button.setEnabled(True) 553 | self.time_slider.setEnabled(True) 554 | self.test_button.setEnabled(True) 555 | 556 | def load_subtitle_file(self, file_name=None): 557 | """ 558 | 打开并加载字幕文件,解析字幕并更新时轴表格 559 | """ 560 | if file_name is None: 561 | if not self.cap or not self.fps: 562 | WarnWindow("请先选择视频文件") 563 | return 564 | options = QFileDialog.Options() 565 | options |= QFileDialog.ReadOnly 566 | file_name, _ = QFileDialog.getOpenFileName( 567 | self, 568 | "选择时轴文件", 569 | "", 570 | "时轴文件 (*.ass);;所有文件 (*)", 571 | options=options, 572 | ) 573 | if not file_name: 574 | return 575 | 576 | with open(file_name, "r", encoding="utf-8") as f: 577 | content = f.read() 578 | self.subtitle_path = file_name 579 | 580 | # 分析字幕文件数据 581 | dialogue_list = [] 582 | for line in content.split("\n"): 583 | temp = self.parse_ass_line(line) 584 | if temp and temp["Type"] == "Dialogue": 585 | temp["Start"] = int(temp["Start"] * self.fps) + 1 586 | temp["End"] = int(temp["End"] * self.fps) + 1 587 | dialogue_list.append(temp) 588 | 589 | # 整理成时轴表格 590 | self.table = {} 591 | flag = True 592 | for idx, dialogue in enumerate(dialogue_list): 593 | title = dialogue["Style"] 594 | if title not in self.table: 595 | self.table[title] = [None] * self.total_frames 596 | for i in range(dialogue["Start"], dialogue["End"]): 597 | try: 598 | if self.table[title][i] is None and flag: 599 | self.table[title][i] = ( 600 | f"{idx + 1} - {dialogue['Name']}" 601 | if dialogue["Name"] 602 | else f"{idx + 1}" 603 | ) 604 | except: 605 | ErrorWindow("时轴超过视频范围,请检查时轴") 606 | flag = False 607 | self.update_table(self.table) 608 | 609 | # 图像修复算法相关函数 610 | def set_inpainter(self): 611 | return Inpainter( 612 | self.algorithm_combo.currentText(), 613 | self.stroke_input, 614 | self.x_offset_input, 615 | self.y_offset_input, 616 | self.autosub_input, 617 | ) 618 | 619 | def test(self): 620 | """ 621 | 测试图像修复算法,在当前选区内执行图像修复操作 622 | """ 623 | self.subtitle_table.clearSelection() 624 | self.inpainter = self.set_inpainter() 625 | 626 | frame = self.current_frame.copy() 627 | 628 | # 单选区直接修复 629 | if self.subtitle_path == "": 630 | region = self.selected_regions[0] 631 | x1, x2, y1, y2 = self.confirm_region(region["region"]) 632 | frame_area = frame[y1:y2, x1:x2] 633 | if frame_area.size > 0: 634 | frame_area_inpainted, _ = self.inpainter.inpaint_text( 635 | frame_area, region["binary"] 636 | ) 637 | frame[y1:y2, x1:x2] = frame_area_inpainted 638 | self.update_frame_output(frame) 639 | return 640 | 641 | # 多选区根据 self.table 642 | time_table = list(self.table.values()) 643 | for region_id, region in enumerate(self.selected_regions): 644 | if time_table[region_id][self.time_slider.value()] is not None: 645 | x1, x2, y1, y2 = self.confirm_region(region["region"]) 646 | frame_area = frame[y1:y2, x1:x2] 647 | if frame_area.size > 0: 648 | frame_area_inpainted, _ = self.inpainter.inpaint_text( 649 | frame_area, region["binary"] 650 | ) 651 | frame[y1:y2, x1:x2] = frame_area_inpainted 652 | self.update_frame_output(frame) 653 | 654 | def run(self): 655 | """ 656 | 运行图像修复任务,根据选区和字幕表信息批量进行修复 657 | """ 658 | self.subtitle_table.clearSelection() 659 | self.inpainter = self.set_inpainter() 660 | self.save_config() 661 | 662 | # 启动工作线程 663 | if not self.worker_thread or not self.worker_thread.isRunning(): 664 | regions = [ 665 | { 666 | "region": self.confirm_region(region["region"]), 667 | "binary": region["binary"], 668 | } 669 | for region in self.selected_regions 670 | ] 671 | time_table = list(self.table.values()) 672 | self.progress = ProgressWindow() 673 | self.worker_thread = Worker( 674 | self.video_path, regions, self.inpainter, time_table 675 | ) 676 | self.worker_thread.start_button.connect(self.start_button.setEnabled) 677 | self.worker_thread.time_slider.connect(self.time_slider.setEnabled) 678 | self.worker_thread.test_button.connect(self.test_button.setEnabled) 679 | self.worker_thread.update_input_frame.connect(self.update_frame_input) 680 | self.worker_thread.update_output_frame.connect(self.update_frame_output) 681 | self.worker_thread.update_progress.connect(self.progress.update_progress) 682 | self.worker_thread.update_table.connect(self.complete_cell) 683 | self.progress.cancel_signal.connect(self.worker_thread.stop) 684 | self.worker_thread.result_signal.connect(self.handle_result) 685 | self.worker_thread.start() 686 | 687 | def handle_result(self, result): 688 | """ 689 | 处理工作线程返回的结果 690 | """ 691 | if result["status"] != "Success": 692 | regions = self.selected_regions.copy() 693 | if self.video_path: 694 | self.load_video_file(self.video_path) 695 | if self.subtitle_path: 696 | self.load_subtitle_file(self.subtitle_path) 697 | self.selected_regions = regions 698 | if result["status"] == "Error": 699 | ErrorWindow(result["message"]) 700 | return 701 | 702 | # 更新帧画面显示 703 | def update_frame(self, frame_number=None): 704 | """ 705 | 根据给定帧号更新当前显示的视频帧 706 | """ 707 | if not self.cap or not self.cap.isOpened(): 708 | ErrorWindow("视频未打开") 709 | return 710 | 711 | if frame_number is not None: 712 | self.cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number) 713 | ret, self.current_frame = self.cap.read() 714 | if ret: 715 | frame_rgb = cv2.cvtColor(self.current_frame, cv2.COLOR_BGR2RGB) 716 | height, weight, channel = frame_rgb.shape 717 | bytes_per_line = channel * weight 718 | q_image = QImage( 719 | frame_rgb.data, weight, height, bytes_per_line, QImage.Format_RGB888 720 | ) 721 | pixmap = QPixmap.fromImage(q_image) 722 | self.pixmap = pixmap 723 | 724 | # 在 QLabel 中居中显示图像 725 | self.video_label_input.setPixmap( 726 | self.pixmap.scaled(self.video_label_input.size(), Qt.KeepAspectRatio) 727 | ) 728 | self.video_label_output.setPixmap( 729 | pixmap.scaled(self.video_label_output.size(), Qt.KeepAspectRatio) 730 | ) 731 | self.video_label_input.setAlignment(Qt.AlignCenter) 732 | self.video_label_output.setAlignment(Qt.AlignCenter) 733 | 734 | if frame_number is not None: 735 | self.update_time_label(frame_number) 736 | else: 737 | ErrorWindow("无法读取视频帧") 738 | return 739 | 740 | def update_frame_input(self, frame): 741 | """ 742 | 更新输入窗口的视频帧显示 743 | """ 744 | frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 745 | height, weight, channel = frame_rgb.shape 746 | bytes_per_line = channel * weight 747 | q_image = QImage( 748 | frame_rgb.data, weight, height, bytes_per_line, QImage.Format_RGB888 749 | ) 750 | self.pixmap = QPixmap.fromImage(q_image) 751 | 752 | # 在 QLabel 中居中显示图像 753 | self.video_label_input.setPixmap( 754 | self.pixmap.scaled(self.video_label_input.size(), Qt.KeepAspectRatio) 755 | ) 756 | self.video_label_input.setAlignment(Qt.AlignCenter) 757 | 758 | def update_frame_output(self, frame): 759 | """ 760 | 更新输出窗口的视频帧显示 761 | """ 762 | frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) 763 | height, weight, channel = frame_rgb.shape 764 | bytes_per_line = channel * weight 765 | q_image = QImage( 766 | frame_rgb.data, weight, height, bytes_per_line, QImage.Format_RGB888 767 | ) 768 | pixmap = QPixmap.fromImage(q_image) 769 | 770 | # 在 QLabel 中居中显示图像 771 | self.video_label_output.setPixmap( 772 | pixmap.scaled(self.video_label_output.size(), Qt.KeepAspectRatio) 773 | ) 774 | self.video_label_output.setAlignment(Qt.AlignCenter) 775 | 776 | # 表格处理相关 777 | def update_table(self, table): 778 | """ 779 | 根据 self.table 更新时轴表格 780 | """ 781 | self.subtitle_table.setRowCount(len(table)) 782 | for i, title in enumerate(table): 783 | title_label = QTableWidgetItem(title) 784 | self.subtitle_table.setVerticalHeaderItem(i, title_label) 785 | for j, text in enumerate(table[title]): 786 | if text is not None: 787 | self.set_cell(i, j, QColor("#C5E4FD"), text, QColor("#000000")) 788 | else: 789 | self.set_cell(i, j, QColor("#232629")) 790 | self.selected_regions = [ 791 | {"region": QRect(0, 0, 1, 0), "binary": True} for _ in table 792 | ] 793 | 794 | def init_table(self): 795 | """ 796 | 打开视频时初始化时轴表格 797 | """ 798 | self.subtitle_table.setColumnCount(self.total_frames) 799 | for i in range(self.total_frames): 800 | current_time = i / self.fps 801 | time_label = QTableWidgetItem(f"{i}\n{self.format_time2(current_time)}") 802 | self.subtitle_table.setHorizontalHeaderItem(i, time_label) 803 | self.table = {"default": [" "] * self.total_frames} 804 | self.update_table(self.table) 805 | 806 | def roll_table(self, col): 807 | """ 808 | 根据时间轴滑块的值滚动字幕表格 809 | """ 810 | target_position = max(0, col - 2) 811 | self.subtitle_table.horizontalScrollBar().setValue(target_position) 812 | 813 | def locate_table(self, row, col): 814 | """ 815 | 根据时间轴滑块的值滚动字幕表格 816 | """ 817 | target_position = max(0, col - 2) 818 | self.subtitle_table.horizontalScrollBar().setValue(target_position) 819 | target_position = max(0, row) 820 | self.subtitle_table.verticalScrollBar().setValue(target_position) 821 | 822 | def complete_cell(self, row, col, content=""): 823 | """ 824 | 标记字幕表格中的某一单元格,表示对应的帧已经完成图像修复。 825 | """ 826 | self.locate_table(row, col) 827 | if row > self.subtitle_table.rowCount() - 1: 828 | self.subtitle_table.setRowCount(row + 1) 829 | self.table[str(row + 1)] = [None] * self.total_frames 830 | for j in range(self.total_frames): 831 | self.set_cell(row, j, QColor("#232629")) 832 | if row > -1: 833 | self.set_cell(row, col, QColor("#14445B"), content) 834 | self.locate_table(row, col) 835 | 836 | def set_cell(self, row, col, bgcolor, text="", textcolor=QColor("#ffffff")): 837 | """设置表格单元格""" 838 | item = QTableWidgetItem(text) 839 | self.subtitle_table.setItem(row, col, item) 840 | item.setBackground(bgcolor) 841 | item.setForeground(textcolor) 842 | keys = list(self.table.keys()) 843 | self.table[keys[row]][col] = text 844 | 845 | def select_region(self, logical_index): 846 | """ 847 | 点击行标题时,触发相应的选区绘制 848 | """ 849 | self.draw_id = logical_index 850 | print(f"Row {logical_index} clicked, draw_id set to {self.draw_id}") 851 | 852 | def selected_cell(self): 853 | """ 854 | 单元格选中事件 855 | """ 856 | selected_items = self.subtitle_table.selectedItems() 857 | # 选中单个单元格跳转 858 | if len(selected_items) == 1: 859 | item = selected_items[0] 860 | self.update_frame(item.column()) 861 | self.time_slider.setValue(item.column()) 862 | self.update_time_label(item.column()) 863 | 864 | def change_region_color(self, logical_index): 865 | msg_box = QMessageBox(self) 866 | msg_box.setWindowTitle("文字颜色为?") 867 | msg_box.setText("请选择文字颜色:") 868 | msg_box.addButton("黑字或白字", QMessageBox.YesRole) 869 | gray_button = msg_box.addButton("灰字", QMessageBox.NoRole) 870 | msg_box.exec_() 871 | 872 | binary = not (msg_box.clickedButton() == gray_button) 873 | self.selected_regions[logical_index]["binary"] = binary 874 | print(f"Row {logical_index} clicked, binary set to {binary}") 875 | 876 | def change_state_cell(self, row, column): 877 | item = self.subtitle_table.item(row, column) 878 | if item: 879 | current_color = item.background().color() 880 | color1 = QColor("#C5E4FD") # 选择 881 | color2 = QColor("#232629") # 未选择 882 | 883 | if current_color == color1: 884 | item.setBackground(color2) 885 | self.table[list(self.table.keys())[row]][column] = None 886 | else: 887 | item.setBackground(color1) 888 | self.table[list(self.table.keys())[row]][column] = "1" 889 | 890 | # 绘制红框相关事件 891 | def start_drawing(self, event): 892 | """ 893 | 开始绘制选区,响应鼠标按下事件,并记录起始点坐标。 894 | """ 895 | if event.button() == Qt.LeftButton and self.cap is not None: 896 | self.start_point = self.region_offset(event.pos()) 897 | self.is_drawing = True 898 | binary = self.selected_regions[self.draw_id]["binary"] 899 | self.selected_regions[self.draw_id] = { 900 | "region": QRect(0, 0, 0, 0), # 清空以前的选区 901 | "binary": binary, 902 | } 903 | 904 | def update_drawing(self, event): 905 | """ 906 | 更新选区绘制,响应鼠标移动事件,根据当前鼠标位置动态绘制矩形区域。 907 | """ 908 | if self.is_drawing and self.cap is not None: 909 | self.end_point = self.region_offset(event.pos()) 910 | binary = self.selected_regions[self.draw_id]["binary"] 911 | self.selected_regions[self.draw_id] = { 912 | "region": QRect(self.start_point, self.end_point), 913 | "binary": binary, 914 | } 915 | self.update() 916 | 917 | def end_drawing(self, event): 918 | """ 919 | 结束选区绘制,响应鼠标松开事件,保存绘制完成的矩形区域。 920 | """ 921 | if event.button() == Qt.LeftButton and self.cap is not None: 922 | self.end_point = self.region_offset(event.pos()) 923 | binary = self.selected_regions[self.draw_id]["binary"] 924 | self.selected_regions[self.draw_id] = { 925 | "region": QRect(self.start_point, self.end_point), 926 | "binary": binary, 927 | } 928 | self.update() 929 | 930 | def paintEvent(self, event): 931 | """ 932 | 绘制事件(更新视频帧&红框显示) 933 | """ 934 | if ( 935 | self.cap != None 936 | and not self.selected_regions[self.draw_id]["region"].isNull() 937 | ): 938 | pixmap = self.pixmap.scaled( 939 | self.video_label_input.size(), Qt.KeepAspectRatio 940 | ) 941 | painter = QPainter(pixmap) 942 | pen = QPen(Qt.red, 2, Qt.SolidLine) 943 | painter.setPen(pen) 944 | painter.drawRect(self.selected_regions[self.draw_id]["region"]) 945 | painter.end() 946 | self.video_label_input.setPixmap(pixmap) 947 | self.video_label_input.update() 948 | 949 | # 选区坐标处理相关 950 | def init_offset(self): 951 | """ 952 | 初始化选区的偏移量,视频 label 和实际视频帧之间的计算偏差。 953 | """ 954 | label_width = self.video_label_input.width() 955 | label_height = self.video_label_input.height() 956 | video_width, video_height = self.video_frame_size 957 | 958 | scaled_video_width = video_width * label_height / video_height 959 | scaled_video_height = video_height * label_width / video_width 960 | if scaled_video_width <= label_width: 961 | self.x_offset = (label_width - scaled_video_width) / 2 962 | self.y_offset = 0 963 | else: 964 | self.x_offset = 0 965 | self.y_offset = (label_height - scaled_video_height) / 2 966 | 967 | def region_offset(self, point): 968 | """计算区域偏移量(竖屏视频x有偏移)""" 969 | label_width = self.video_label_input.width() 970 | label_height = self.video_label_input.height() 971 | 972 | # Map the point 973 | video_x = point.x() - self.x_offset 974 | video_y = point.y() - self.y_offset 975 | 976 | # area bound 977 | video_x = min(max(video_x, 0), (label_width - self.x_offset * 2)) 978 | video_y = min(max(video_y, 0), (label_height - self.y_offset * 2)) 979 | 980 | return QPoint(video_x, video_y) 981 | 982 | def region_to_video(self, point): 983 | """将label坐标转换为frame坐标""" 984 | label_width = self.video_label_input.width() 985 | label_height = self.video_label_input.height() 986 | video_width, video_height = self.video_frame_size 987 | 988 | # Calculate the scale ratio 989 | scale_width = video_width / (label_width - self.x_offset * 2) 990 | scale_height = video_height / (label_height - self.y_offset * 2) 991 | 992 | # Convert the coordinates to original video coordinates 993 | x1 = int((point.x()) * scale_width) 994 | y1 = int((point.y()) * scale_height) 995 | 996 | return x1, y1 997 | 998 | def confirm_region(self, region): 999 | """获取区域坐标""" 1000 | if not region.isNull(): 1001 | x1, y1 = self.region_to_video(region.topLeft()) 1002 | x2, y2 = self.region_to_video(region.bottomRight()) 1003 | return (min(x1, x2), max(x1, x2), min(y1, y2), max(y1, y2)) 1004 | else: 1005 | video_width, video_height = self.video_frame_size 1006 | return 0, video_width - 1, 0, video_height - 1 1007 | 1008 | # 时轴处理相关 1009 | def parse_ass_line(self, line): 1010 | """ 1011 | 解析字幕文件中的单行内容,提取出时间、字幕等信息。 1012 | """ 1013 | # Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text 1014 | pattern = r"^(Dialogue|Comment):\s*(\d+),(\d+:\d+:\d+\.\d+),(\d+:\d+:\d+\.\d+),([^,]*),([^,]*),(\d+),(\d+),(\d+),([^,]*),(.*)$" 1015 | 1016 | match = re.match(pattern, line) 1017 | 1018 | if match: 1019 | parsed = { 1020 | "Type": match.group(1), 1021 | # "Layer": int(match.group(2)), 1022 | "Start": self.calSubTime(match.group(3)), 1023 | "End": self.calSubTime(match.group(4)), 1024 | "Style": match.group(5), 1025 | "Name": match.group(6), 1026 | # "MarginL": int(match.group(7)), 1027 | # "MarginR": int(match.group(8)), 1028 | # "MarginV": int(match.group(9)), 1029 | # "Effect": match.group(10), 1030 | # "Text": match.group(11), 1031 | } 1032 | return parsed 1033 | else: 1034 | return None 1035 | 1036 | @staticmethod 1037 | def format_time(seconds): 1038 | """ 1039 | s -> mm:ss 1040 | """ 1041 | minutes, seconds = divmod(int(seconds), 60) 1042 | return f"{minutes:02d}:{seconds:02d}" 1043 | 1044 | @staticmethod 1045 | def format_time2(seconds): 1046 | """ 1047 | s -> mm:ss.ss 1048 | """ 1049 | milliseconds = int((seconds - int(seconds)) * 1000) 1050 | minutes, seconds = divmod(int(seconds), 60) 1051 | return f"{minutes:02d}:{int(seconds):02d}.{milliseconds:03d}" 1052 | 1053 | @staticmethod 1054 | def calSubTime(t): 1055 | """ 1056 | h:mm:ss.ss -> s 1057 | """ 1058 | h, m, s = t.split(":") 1059 | h = int(h) 1060 | m = int(m) 1061 | s = float(s) 1062 | 1063 | total_seconds = h * 3600 + m * 60 + (s - 0.01) 1064 | total_seconds = max(total_seconds, 0) 1065 | return total_seconds 1066 | 1067 | def update_time_label(self, frame_number): 1068 | """ 1069 | 更新上方控制栏时间显示 1070 | """ 1071 | if self.cap: 1072 | current_time = frame_number / self.fps 1073 | total_time = self.total_frames / self.fps 1074 | self.time_label.setText( 1075 | f"{self.format_time(current_time)} / {self.format_time(total_time)}" 1076 | ) 1077 | 1078 | # 窗口事件 1079 | def closeEvent(self, event): 1080 | """ 1081 | 窗口关闭事件,释放视频捕获资源。 1082 | """ 1083 | if self.cap: 1084 | self.cap.release() 1085 | super().closeEvent(event) 1086 | 1087 | def resizeEvent(self, event): 1088 | """ 1089 | 窗口调整大小事件,更新视频帧显示。 1090 | """ 1091 | super().resizeEvent(event) 1092 | if hasattr(self, "current_frame"): 1093 | self.update_frame() 1094 | 1095 | 1096 | def main(): 1097 | QApplication.setAttribute(Qt.AA_UseHighDpiPixmaps, True) 1098 | QApplication.setAttribute(Qt.AA_EnableHighDpiScaling, True) 1099 | app = QApplication(sys.argv) 1100 | resources_path = Path("resources") 1101 | 1102 | # Splash screen 1103 | splash_path = resources_path / "splash.png" 1104 | splash_img = QPixmap(str(splash_path)).scaled( 1105 | 300, 300, Qt.KeepAspectRatio, Qt.SmoothTransformation 1106 | ) 1107 | splash = QSplashScreen(splash_img) 1108 | splash.show() 1109 | 1110 | # Apply QSS styling 1111 | qss_path = resources_path / "qdark.qss" 1112 | with open(qss_path, "r") as qss_file: 1113 | app.setStyleSheet(qss_file.read()) 1114 | 1115 | app.setFont(QFont("Microsoft YaHei", 9)) 1116 | 1117 | # Create and setup main window 1118 | main_window = MainWindow() 1119 | favicon_path = resources_path / "favicon.ico" 1120 | main_window.setWindowIcon(QIcon(str(favicon_path))) 1121 | 1122 | # Set window size and position 1123 | screen_geometry = app.primaryScreen().geometry() 1124 | main_window.resize( 1125 | int(screen_geometry.width() * 0.75), int(screen_geometry.height() * 0.75) 1126 | ) 1127 | main_window.move( 1128 | (screen_geometry.width() - main_window.width()) // 2, 1129 | (screen_geometry.height() - main_window.height()) // 2, 1130 | ) 1131 | 1132 | main_window.showMaximized() 1133 | splash.finish(main_window) 1134 | 1135 | sys.exit(app.exec_()) 1136 | 1137 | 1138 | if __name__ == "__main__": 1139 | main() 1140 | --------------------------------------------------------------------------------