Compare commits

...

16 Commits

Author SHA1 Message Date
Julian Freeman
0541dbfd69 fix image type bug 2026-01-20 22:08:56 -04:00
Julian Freeman
52fb288bed add license and notes 2026-01-19 16:17:10 -04:00
Julian Freeman
2ce9e5471e update version 2026-01-19 15:38:58 -04:00
Julian Freeman
2c89ef42b3 support gpu 2026-01-19 15:35:53 -04:00
Julian Freeman
330a62027c import ocr ares 2026-01-19 15:16:38 -04:00
Julian Freeman
302f106ae6 css 2026-01-19 15:12:59 -04:00
Julian Freeman
3ed264f349 left and right key 2026-01-19 15:00:47 -04:00
Julian Freeman
99c442663a import scrollbar 2026-01-19 14:52:54 -04:00
Julian Freeman
a01277a11c adjust ui 2026-01-19 14:47:44 -04:00
Julian Freeman
0cf429fff2 improve ui 2026-01-19 13:41:51 -04:00
Julian Freeman
d25f87abe0 improve 2026-01-19 13:30:40 -04:00
Julian Freeman
84ae92d1e3 fix export bug 2026-01-19 13:18:57 -04:00
Julian Freeman
6cb5b7a61f ocr cover fix 2026-01-19 12:58:05 -04:00
Julian Freeman
eb251b5eac ocr detect 2026-01-19 12:42:05 -04:00
Julian Freeman
6439759b04 support restore 2026-01-19 12:18:08 -04:00
Julian Freeman
f96033e421 lama remove 2026-01-19 12:08:36 -04:00
16 changed files with 1386 additions and 283 deletions

3
.gitignore vendored
View File

@@ -22,3 +22,6 @@ dist-ssr
*.njsproj
*.sln
*.sw?
*.onnx
*.dll

202
LICENSE.txt Normal file
View File

@@ -0,0 +1,202 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.

View File

@@ -1,3 +1,7 @@
# Watermark Wizard
Generated by Gemini
Models:
- LaMa: https://huggingface.co/Carve/LaMa-ONNX/blob/main/lama_fp32.onnx
- en_PP-OCRv3_det_infer: https://huggingface.co/SWHL/RapidOCR/blob/main/PP-OCRv4/en_PP-OCRv3_det_infer.onnx

View File

@@ -1,7 +1,7 @@
{
"name": "watermark-wizard",
"private": true,
"version": "0.1.0",
"version": "1.0.1",
"type": "module",
"scripts": {
"dev": "vite",

370
src-tauri/Cargo.lock generated
View File

@@ -212,6 +212,12 @@ version = "0.22.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "72b3254f16251a8381aa12e40e3c4d2f0199f8c6508fbecb9d91f575e0fbb8c6"
[[package]]
name = "base64ct"
version = "1.8.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "2af50177e190e07a26ab74f8b1efbfe2ef87da2116221318cb1c2e82baf7de06"
[[package]]
name = "bit_field"
version = "0.10.3"
@@ -476,6 +482,16 @@ dependencies = [
"version_check",
]
[[package]]
name = "core-foundation"
version = "0.9.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "91e195e091a93c46f7102ec7818a2aa394e1e1771c3ab4825963fa03e45afb8f"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "core-foundation"
version = "0.10.1"
@@ -499,9 +515,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fa95a34622365fa5bbf40b20b75dba8dfa8c94c734aea8ac9a5ca38af14316f1"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics-types",
"foreign-types",
"foreign-types 0.5.0",
"libc",
]
@@ -512,7 +528,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3d44a101f213f6c4cdc1853d4b78aef6db6bdfa3468798cc1d9912f4735013eb"
dependencies = [
"bitflags 2.10.0",
"core-foundation",
"core-foundation 0.10.1",
"libc",
]
@@ -665,6 +681,16 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "der"
version = "0.7.10"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "e7c1832837b905bbfb5101e07cc24c8deddf52f93225eee6ead5f4d63d53ddcb"
dependencies = [
"pem-rfc7468",
"zeroize",
]
[[package]]
name = "deranged"
version = "0.5.5"
@@ -870,6 +896,16 @@ dependencies = [
"typeid",
]
[[package]]
name = "errno"
version = "0.3.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
dependencies = [
"libc",
"windows-sys 0.61.2",
]
[[package]]
name = "exr"
version = "1.74.0"
@@ -885,6 +921,12 @@ dependencies = [
"zune-inflate",
]
[[package]]
name = "fastrand"
version = "2.3.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "37909eebbb50d72f9059c3b6d82c0463f2ff062c9e95845c43a6c9c0355411be"
[[package]]
name = "fax"
version = "0.2.6"
@@ -946,6 +988,15 @@ version = "1.0.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "3f9eec918d3f24069decb9af1554cad7c880e2da24a9afd88aca000531ab82c1"
[[package]]
name = "foreign-types"
version = "0.3.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6f339eb8adc052cd2ca78910fda869aefa38d22d5cb648e6485e4d3fc06f3b1"
dependencies = [
"foreign-types-shared 0.1.1",
]
[[package]]
name = "foreign-types"
version = "0.5.0"
@@ -953,7 +1004,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d737d9aa519fb7b749cbc3b962edcf310a8dd1f4b67c91c4f83975dbdd17d965"
dependencies = [
"foreign-types-macros",
"foreign-types-shared",
"foreign-types-shared 0.3.1",
]
[[package]]
@@ -967,6 +1018,12 @@ dependencies = [
"syn 2.0.114",
]
[[package]]
name = "foreign-types-shared"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "00b0228411908ca8685dba7fc2cdd70ec9990a6e753e89b6ac91a84c40fbaf4b"
[[package]]
name = "foreign-types-shared"
version = "0.3.1"
@@ -1417,6 +1474,12 @@ version = "0.4.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "7f24254aa9a54b5c858eaee2f5bccdb46aaf0e486a595ed5fd8f86ba55232a70"
[[package]]
name = "hmac-sha256"
version = "1.1.12"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ad6880c8d4a9ebf39c6e8b77007ce223f646a4d21ce29d99f70cb16420545425"
[[package]]
name = "html5ever"
version = "0.29.1"
@@ -1944,7 +2007,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "6e9ec52138abedcc58dc17a7c6c0c00a2bdb4f3427c7f63fa97fd0d859155caf"
dependencies = [
"gtk-sys",
"libloading",
"libloading 0.7.4",
"once_cell",
]
@@ -1974,6 +2037,16 @@ dependencies = [
"winapi",
]
[[package]]
name = "libloading"
version = "0.9.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "754ca22de805bb5744484a5b151a9e1a8e837d5dc232c2d7d8c2e3492edc8b60"
dependencies = [
"cfg-if",
"windows-link 0.2.1",
]
[[package]]
name = "libm"
version = "0.2.15"
@@ -1990,6 +2063,12 @@ dependencies = [
"libc",
]
[[package]]
name = "linux-raw-sys"
version = "0.11.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "df1d3c3b53da64cf5760482273a98e575c651a67eec7f77df96b5b642de8f039"
[[package]]
name = "litemap"
version = "0.8.1"
@@ -2020,6 +2099,12 @@ dependencies = [
"imgref",
]
[[package]]
name = "lzma-rust2"
version = "0.15.7"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1670343e58806300d87950e3401e820b519b9384281bbabfb15e3636689ffd69"
[[package]]
name = "mac"
version = "0.1.1"
@@ -2165,6 +2250,53 @@ dependencies = [
"typenum",
]
[[package]]
name = "native-tls"
version = "0.2.14"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "87de3442987e9dbec73158d5c715e7ad9072fda936bb03d19d7fa10e00520f0e"
dependencies = [
"libc",
"log",
"openssl",
"openssl-probe",
"openssl-sys",
"schannel",
"security-framework",
"security-framework-sys",
"tempfile",
]
[[package]]
name = "ndarray"
version = "0.16.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "882ed72dce9365842bf196bdeedf5055305f11fc8c03dee7bb0194a6cad34841"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndarray"
version = "0.17.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "520080814a7a6b4a6e9070823bb24b4531daac8c4627e08ba5de8c5ef2f2752d"
dependencies = [
"matrixmultiply",
"num-complex",
"num-integer",
"num-traits",
"portable-atomic",
"portable-atomic-util",
"rawpointer",
]
[[package]]
name = "ndk"
version = "0.9.0"
@@ -2553,12 +2685,81 @@ version = "1.21.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "42f5e15c9953c5e4ccceeb2e7382a716482c34515315f7b03532b8b4e8393d2d"
[[package]]
name = "openssl"
version = "0.10.75"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "08838db121398ad17ab8531ce9de97b244589089e290a384c900cb9ff7434328"
dependencies = [
"bitflags 2.10.0",
"cfg-if",
"foreign-types 0.3.2",
"libc",
"once_cell",
"openssl-macros",
"openssl-sys",
]
[[package]]
name = "openssl-macros"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "a948666b637a0f465e8564c73e89d4dde00d72d4d473cc972f390fc3dcee7d9c"
dependencies = [
"proc-macro2",
"quote",
"syn 2.0.114",
]
[[package]]
name = "openssl-probe"
version = "0.1.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d05e27ee213611ffe7d6348b942e8f942b37114c00cc03cec254295a4a17852e"
[[package]]
name = "openssl-sys"
version = "0.9.111"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "82cab2d520aa75e3c58898289429321eb788c3106963d0dc886ec7a5f4adc321"
dependencies = [
"cc",
"libc",
"pkg-config",
"vcpkg",
]
[[package]]
name = "option-ext"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "04744f49eae99ab78e0d5c0b603ab218f515ea8cfe5a456d7629ad883a3b6e7d"
[[package]]
name = "ort"
version = "2.0.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4a5df903c0d2c07b56950f1058104ab0c8557159f2741782223704de9be73c3c"
dependencies = [
"libloading 0.9.0",
"ndarray 0.17.2",
"ort-sys",
"smallvec",
"tracing",
"ureq",
]
[[package]]
name = "ort-sys"
version = "2.0.0-rc.11"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "06503bb33f294c5f1ba484011e053bfa6ae227074bdb841e9863492dc5960d4b"
dependencies = [
"hmac-sha256",
"lzma-rust2",
"ureq",
]
[[package]]
name = "owned_ttf_parser"
version = "0.25.1"
@@ -2628,6 +2829,15 @@ version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "35fb2e5f958ec131621fdd531e9fc186ed768cbe395337403ae56c17a74c68ec"
[[package]]
name = "pem-rfc7468"
version = "0.7.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "88b39c9bfcfc231068454382784bb460aae594343fb030d46e9f50a645418412"
dependencies = [
"base64ct",
]
[[package]]
name = "percent-encoding"
version = "2.3.2"
@@ -2825,6 +3035,21 @@ dependencies = [
"miniz_oxide",
]
[[package]]
name = "portable-atomic"
version = "1.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f89776e4d69bb58bc6993e99ffa1d11f228b839984854c7daeb5d37f87cbe950"
[[package]]
name = "portable-atomic-util"
version = "0.2.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d8a2f0d8d040d7848a709caf78912debcc3f33ee4b3cac47d73d1e1069e83507"
dependencies = [
"portable-atomic",
]
[[package]]
name = "potential_utf"
version = "0.1.4"
@@ -3335,6 +3560,28 @@ dependencies = [
"semver",
]
[[package]]
name = "rustix"
version = "1.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "146c9e247ccc180c1f61615433868c99f3de3ae256a30a43b49f67c2d9171f34"
dependencies = [
"bitflags 2.10.0",
"errno",
"libc",
"linux-raw-sys",
"windows-sys 0.61.2",
]
[[package]]
name = "rustls-pki-types"
version = "1.14.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd"
dependencies = [
"zeroize",
]
[[package]]
name = "rustversion"
version = "1.0.22"
@@ -3365,6 +3612,15 @@ dependencies = [
"winapi-util",
]
[[package]]
name = "schannel"
version = "0.1.28"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "891d81b926048e76efe18581bf793546b4c0eaf8448d72be8de2bbee5fd166e1"
dependencies = [
"windows-sys 0.61.2",
]
[[package]]
name = "schemars"
version = "0.8.22"
@@ -3422,6 +3678,29 @@ version = "1.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "94143f37725109f92c262ed2cf5e59bce7498c01bcc1502d7b9afe439a4e9f49"
[[package]]
name = "security-framework"
version = "2.11.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "897b2245f0b511c87893af39b033e5ca9cce68824c4d7e7630b5a1d339658d02"
dependencies = [
"bitflags 2.10.0",
"core-foundation 0.9.4",
"core-foundation-sys",
"libc",
"security-framework-sys",
]
[[package]]
name = "security-framework-sys"
version = "2.15.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "cc1f0cbffaac4852523ce30d8bd3c5cdc873501d96ff467ca09b6767bb8cd5c0"
dependencies = [
"core-foundation-sys",
"libc",
]
[[package]]
name = "selectors"
version = "0.24.0"
@@ -3699,6 +3978,17 @@ dependencies = [
"windows-sys 0.60.2",
]
[[package]]
name = "socks"
version = "0.3.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0c3dbbd9ae980613c6dd8e28a9407b50509d3803b57624d5dfe8315218cd58b"
dependencies = [
"byteorder",
"libc",
"winapi",
]
[[package]]
name = "softbuffer"
version = "0.4.8"
@@ -3858,7 +4148,7 @@ checksum = "f3a753bdc39c07b192151523a3f77cd0394aa75413802c883a0f6f6a0e5ee2e7"
dependencies = [
"bitflags 2.10.0",
"block2",
"core-foundation",
"core-foundation 0.10.1",
"core-graphics",
"crossbeam-channel",
"dispatch",
@@ -4180,6 +4470,19 @@ dependencies = [
"toml 0.9.11+spec-1.1.0",
]
[[package]]
name = "tempfile"
version = "3.24.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "655da9c7eb6305c55742045d5a8d2037996d61d8de95806335c7c86ce0f82e9c"
dependencies = [
"fastrand",
"getrandom 0.3.4",
"once_cell",
"rustix",
"windows-sys 0.61.2",
]
[[package]]
name = "tendril"
version = "0.4.3"
@@ -4572,6 +4875,36 @@ version = "1.12.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f6ccf251212114b54433ec949fd6a7841275f9ada20dddd2f29e9ceea4501493"
[[package]]
name = "ureq"
version = "3.1.4"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d39cb1dbab692d82a977c0392ffac19e188bd9186a9f32806f0aaa859d75585a"
dependencies = [
"base64 0.22.1",
"der",
"log",
"native-tls",
"percent-encoding",
"rustls-pki-types",
"socks",
"ureq-proto",
"utf-8",
"webpki-root-certs",
]
[[package]]
name = "ureq-proto"
version = "0.5.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d81f9efa9df032be5934a46a068815a10a042b494b6a58cb0a1a97bb5467ed6f"
dependencies = [
"base64 0.22.1",
"http",
"httparse",
"log",
]
[[package]]
name = "url"
version = "2.5.8"
@@ -4632,6 +4965,12 @@ dependencies = [
"wasm-bindgen",
]
[[package]]
name = "vcpkg"
version = "0.2.15"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "accd4ea62f7bb7a82fe23066fb0957d48ef677f6eeb8215f372f52e48bb32426"
[[package]]
name = "version-compare"
version = "0.2.1"
@@ -4778,11 +5117,13 @@ dependencies = [
[[package]]
name = "watermark-wizard"
version = "0.1.0"
version = "1.0.1"
dependencies = [
"ab_glyph",
"image",
"imageproc",
"ndarray 0.16.1",
"ort",
"rayon",
"serde",
"serde_json",
@@ -4845,6 +5186,15 @@ dependencies = [
"system-deps",
]
[[package]]
name = "webpki-root-certs"
version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "36a29fc0408b113f68cf32637857ab740edfafdf460c326cd2afaa2d84cc05dc"
dependencies = [
"rustls-pki-types",
]
[[package]]
name = "webview2-com"
version = "0.38.2"
@@ -5498,6 +5848,12 @@ dependencies = [
"synstructure",
]
[[package]]
name = "zeroize"
version = "1.8.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b97154e67e32c85465826e8bcc1c59429aaaf107c1e4a9e53c8d8ccd5eff88d0"
[[package]]
name = "zerotrie"
version = "0.2.3"

View File

@@ -1,8 +1,8 @@
[package]
name = "watermark-wizard"
version = "0.1.0"
version = "1.0.1"
description = "A Tauri App handles watermarks"
authors = ["you"]
authors = ["Julian"]
edition = "2021"
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
@@ -26,4 +26,6 @@ rayon = "1.10"
tauri-plugin-dialog = "2"
imageproc = "0.25"
ab_glyph = "0.2.23"
ort = { version = "=2.0.0-rc.11", features = ["load-dynamic", "directml"] }
ndarray = "0.16"

133
src-tauri/src/lama.rs Normal file
View File

@@ -0,0 +1,133 @@
use crate::ort_ops;
use image::{Rgba, RgbaImage};
use ort::value::Value;
pub fn run_lama_inpainting(
model_path: &std::path::Path,
input_image: &RgbaImage,
mask_image: &image::GrayImage,
) -> Result<RgbaImage, String> {
// 1. Initialize Session
let mut session = ort_ops::create_session(model_path)
.map_err(|e| format!("Failed to create ORT session for LAMA: {}", e))?;
// 2. Preprocess
let target_size = (512, 512);
let resized_img = image::imageops::resize(input_image, target_size.0, target_size.1, image::imageops::FilterType::Triangle);
let resized_mask = image::imageops::resize(mask_image, target_size.0, target_size.1, image::imageops::FilterType::Triangle);
// Flatten Image to Vec<f32> (NCHW: 1, 3, 512, 512)
let channel_stride = (target_size.0 * target_size.1) as usize;
let mut input_data: Vec<f32> = Vec::with_capacity(1 * 3 * channel_stride);
// We need to fill R plane, then G plane, then B plane
let mut r_plane: Vec<f32> = Vec::with_capacity(channel_stride);
let mut g_plane: Vec<f32> = Vec::with_capacity(channel_stride);
let mut b_plane: Vec<f32> = Vec::with_capacity(channel_stride);
for (_x, _y, pixel) in resized_img.enumerate_pixels() {
r_plane.push(pixel[0] as f32 / 255.0f32);
g_plane.push(pixel[1] as f32 / 255.0f32);
b_plane.push(pixel[2] as f32 / 255.0f32);
}
input_data.extend(r_plane);
input_data.extend(g_plane);
input_data.extend(b_plane);
// Flatten Mask to Vec<f32> (NCHW: 1, 1, 512, 512)
let mut mask_data: Vec<f32> = Vec::with_capacity(channel_stride);
for (_x, _y, pixel) in resized_mask.enumerate_pixels() {
let val = if pixel[0] > 127 { 1.0f32 } else { 0.0f32 };
mask_data.push(val);
}
// 3. Inference
// Use (Shape, Data) tuple which implements OwnedTensorArrayData
// Explicitly casting shape to i64 is correct for ORT
let input_shape = vec![1, 3, target_size.1 as i64, target_size.0 as i64];
let input_value = Value::from_array((input_shape, input_data))
.map_err(|e| format!("Failed to create input tensor: {}", e))?;
let mask_shape = vec![1, 1, target_size.1 as i64, target_size.0 as i64];
let mask_value = Value::from_array((mask_shape, mask_data))
.map_err(|e| format!("Failed to create mask tensor: {}", e))?;
let inputs = ort::inputs![
"image" => input_value,
"mask" => mask_value
];
let outputs = session.run(inputs).map_err(|e| format!("Inference failed: {}", e))?;
// Get output tensor
// Just take the first output.
let output_tensor_ref = outputs.values().next()
.ok_or("No output tensor produced by model")?;
let (shape, data) = output_tensor_ref.try_extract_tensor::<f32>()
.map_err(|e| format!("Failed to extract tensor: {}", e))?;
// 4. Post-process
let mut output_img_512 = RgbaImage::new(target_size.0, target_size.1);
if shape.len() < 4 {
return Err(format!("Unexpected output shape: {:?}", shape));
}
let h = 512;
let w = 512;
let channel_stride = (h * w) as usize;
// Safety check on data length
if data.len() < (3 * h * w) as usize {
return Err(format!("Output data size mismatch. Expected {}, got {}", 3*h*w, data.len()));
}
// Auto-detect output range
// If values are already in 0-255 range, multiplying by 255 results in all white image.
let mut max_val = 0.0f32;
// Check a subset of pixels to avoid iterating everything if speed is key, but full scan is safer and fast enough.
for v in data.iter().take(1000) {
if *v > max_val { max_val = *v; }
}
// Heuristic: if max > 2.0, it's likely 0-255. If it's <= 1.0 (or slightly above due to overshoot), it's 0-1.
// LaMa usually outputs -1..1 or 0..1. But some exports differ.
// Let's assume if any value is > 5.0, it is definitely not 0-1 normalized.
let scale_factor = if max_val > 2.0 { 1.0 } else { 255.0 };
for y in 0..h {
for x in 0..w {
let offset = (y * w + x) as usize;
let r_idx = offset;
let g_idx = offset + channel_stride;
let b_idx = offset + 2 * channel_stride;
let r = (data[r_idx] * scale_factor).clamp(0.0, 255.0) as u8;
let g = (data[g_idx] * scale_factor).clamp(0.0, 255.0) as u8;
let b = (data[b_idx] * scale_factor).clamp(0.0, 255.0) as u8;
output_img_512.put_pixel(x, y, Rgba([r, g, b, 255]));
}
}
// Resize back to original
let (orig_w, orig_h) = input_image.dimensions();
let final_inpainted = image::imageops::resize(&output_img_512, orig_w, orig_h, image::imageops::FilterType::Lanczos3);
// 5. Blending
let mut result_image = input_image.clone();
for y in 0..orig_h {
for x in 0..orig_w {
if mask_image.get_pixel(x, y)[0] > 127 {
result_image.put_pixel(x, y, *final_inpainted.get_pixel(x, y));
}
}
}
Ok(result_image)
}

View File

@@ -19,6 +19,14 @@ fn get_cache_dir() -> std::path::PathBuf {
path
}
fn load_image_safe(path: &Path) -> Result<image::DynamicImage, String> {
let bytes = fs::read(path).map_err(|e| {
let exists = path.exists();
format!("读取文件失败 (存在: {}) / Failed to read file '{}': {}", exists, path.to_string_lossy(), e)
})?;
image::load_from_memory(&bytes).map_err(|e| format!("图片解码失败 / Failed to decode image '{}': {}", path.to_string_lossy(), e))
}
fn generate_thumbnail(original_path: &Path) -> Option<String> {
let cache_dir = get_cache_dir();
@@ -35,7 +43,7 @@ fn generate_thumbnail(original_path: &Path) -> Option<String> {
}
// Generate
if let Ok(img) = image::open(original_path) {
if let Ok(img) = load_image_safe(original_path) {
let thumb = img.thumbnail(u32::MAX, 200);
let _file = fs::File::create(&thumb_path).ok()?;
thumb.save_with_format(&thumb_path, image::ImageFormat::Jpeg).ok()?;
@@ -92,6 +100,11 @@ use rayon::prelude::*;
use std::path::Path;
use imageproc::drawing::draw_text_mut;
use ab_glyph::{FontRef, PxScale};
use tauri::{AppHandle, Manager};
pub mod lama;
pub mod ocr;
pub mod ort_ops;
// Embed the font to ensure it's always available without path issues
const FONT_DATA: &[u8] = include_bytes!("../assets/fonts/Roboto-Regular.ttf");
@@ -106,6 +119,7 @@ struct ZcaResult {
#[derive(serde::Deserialize)]
struct ExportImageTask {
path: String,
output_filename: Option<String>,
manual_position: Option<ManualPosition>,
scale: Option<f64>,
opacity: Option<f64>,
@@ -140,19 +154,23 @@ fn parse_hex_color(hex: &str) -> image::Rgba<u8> {
}
#[tauri::command]
async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings, output_dir: String) -> Result<String, String> {
async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings, output_dir: String, mode: String) -> Result<String, String> {
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
// Note: Settings are now resolved per-task
let results: Vec<Result<(), String>> = images.par_iter().map(|task| {
let input_path = Path::new(&task.path);
let img_result = image::open(input_path);
if let Ok(dynamic_img) = img_result {
// Use safe loading helper
let img_result = load_image_safe(input_path);
if let Ok(dynamic_img) = &img_result {
let mut base_img = dynamic_img.to_rgba8();
let (width, height) = base_img.dimensions();
// ONLY EXECUTE WATERMARK LOGIC IF MODE IS 'ADD'
if mode == "add" {
// Determine effective settings (Task > Global)
let eff_scale = task.scale.unwrap_or(watermark.scale);
let eff_opacity = task.opacity.unwrap_or(watermark.opacity);
@@ -214,6 +232,8 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
x = x.max(0);
y = y.max(0);
// SKIP DRAWING if text is empty (e.g. Remove Mode)
if !watermark.text.trim().is_empty() {
// 6. Draw Stroke (Simple 4-direction offset for black outline)
// Stroke alpha should match text alpha
let stroke_color = image::Rgba([0, 0, 0, text_color[3]]);
@@ -239,10 +259,17 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
&font,
&watermark.text,
);
}
} // END IF MODE == ADD
// Save
let file_name = input_path.file_name().unwrap_or_default();
// Prioritize explicitly provided output filename (from original path), fall back to input filename
let file_name = match &task.output_filename {
Some(name) => std::ffi::OsStr::new(name),
None => input_path.file_name().unwrap_or_default()
};
let output_path = Path::new(&output_dir).join(file_name);
let _output_path = Path::new(&output_dir).join(file_name);
// Handle format specific saving
// JPEG does not support Alpha channel. If we save Rgba8 to Jpeg, it might fail or look wrong.
@@ -260,7 +287,7 @@ async fn export_batch(images: Vec<ExportImageTask>, watermark: WatermarkSettings
Ok(())
} else {
Err(format!("Failed to open {}", task.path))
Err(img_result.unwrap_err())
}
}).collect();
@@ -369,7 +396,7 @@ fn calculate_zca_internal(img: &image::DynamicImage) -> Result<ZcaResult, String
#[tauri::command]
fn get_zca_suggestion(path: String) -> Result<ZcaResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
let img = load_image_safe(Path::new(&path))?;
calculate_zca_internal(&img)
}
@@ -382,7 +409,7 @@ struct LayoutResult {
#[tauri::command]
async fn layout_watermark(path: String, text: String, base_scale: f64) -> Result<LayoutResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
let img = load_image_safe(Path::new(&path))?;
let (width, height) = img.dimensions();
let font = FontRef::try_from_slice(FONT_DATA).map_err(|e| format!("Font error: {}", e))?;
@@ -450,52 +477,63 @@ struct Rect {
}
#[tauri::command]
async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
let img = image::open(&path).map_err(|e| e.to_string())?;
let (width, height) = img.dimensions();
let gray = img.to_luma8();
async fn detect_watermark(app: AppHandle, path: String) -> Result<DetectionResult, String> {
let img = load_image_safe(Path::new(&path))?.to_rgba8();
// "Stroke Detection" Algorithm
// Distinguishes "Text" (Thin White Strokes) from "Solid White Areas" (Walls, Sky)
// Logic: A white text pixel must be "sandwiched" by dark pixels within a short distance.
// 1. Try OCR Detection
let ocr_model_path = app.path().resource_dir()
.map_err(|e| e.to_string())?
.join("resources")
.join("en_PP-OCRv3_det_infer.onnx");
if ocr_model_path.exists() {
println!("Using OCR model for detection");
match ocr::run_ocr_detection(&ocr_model_path, &img) {
Ok(boxes) => {
let rects = boxes.into_iter().map(|b| Rect {
x: b.x,
y: b.y,
width: b.width,
height: b.height,
}).collect();
return Ok(DetectionResult { rects });
}
Err(e) => {
eprintln!("OCR Detection failed: {}", e);
// Fallthrough to legacy
}
}
} else {
eprintln!("OCR model not found at {:?}", ocr_model_path);
}
// 2. Legacy Detection (Fallback)
println!("Falling back to legacy detection");
let (width, height) = img.dimensions();
let gray = image::DynamicImage::ImageRgba8(img.clone()).to_luma8();
let cell_size = 10;
let grid_w = (width + cell_size - 1) / cell_size;
let grid_h = (height + cell_size - 1) / cell_size;
let mut grid = vec![false; (grid_w * grid_h) as usize];
// Focus Areas: Top 15%, Bottom 25%
let top_limit = (height as f64 * 0.15) as u32;
let bottom_start = (height as f64 * 0.75) as u32;
let max_stroke_width = 15; // Max pixels for a text stroke thickness
let contrast_threshold = 40; // How much darker the background must be
let brightness_threshold = 200; // Text must be at least this white
let max_stroke_width = 15;
let contrast_threshold = 40;
let brightness_threshold = 200;
for y in 1..height-1 {
if y > top_limit && y < bottom_start { continue; }
for x in 1..width-1 {
let p = gray.get_pixel(x, y)[0];
// 1. Must be Bright
if p < brightness_threshold { continue; }
// 2. Stroke Check
// We check for "Vertical Stroke" (Dark - Bright - Dark vertically)
// OR "Horizontal Stroke" (Dark - Bright - Dark horizontally)
let mut is_stroke = false;
// Check Horizontal Stroke (Vertical boundaries? No, Vertical Stroke has Left/Right boundaries?
// Terminology: "Vertical Stroke" is like 'I'. It has Left/Right boundaries.
// "Horizontal Stroke" is like '-', It has Up/Down boundaries.
// Let's check Left/Right boundaries (Vertical Stroke)
let mut left_bound = false;
let mut right_bound = false;
// Search Left
for k in 1..=max_stroke_width {
if x < k { break; }
let neighbor = gray.get_pixel(x - k, y)[0];
@@ -504,7 +542,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
break;
}
}
// Search Right
if left_bound {
for k in 1..=max_stroke_width {
if x + k >= width { break; }
@@ -519,11 +556,8 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
if left_bound && right_bound {
is_stroke = true;
} else {
// Check Up/Down boundaries (Horizontal Stroke)
let mut up_bound = false;
let mut down_bound = false;
// Search Up
for k in 1..=max_stroke_width {
if y < k { break; }
let neighbor = gray.get_pixel(x, y - k)[0];
@@ -532,7 +566,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
break;
}
}
// Search Down
if up_bound {
for k in 1..=max_stroke_width {
if y + k >= height { break; }
@@ -543,10 +576,7 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
}
}
}
if up_bound && down_bound {
is_stroke = true;
}
if up_bound && down_bound { is_stroke = true; }
}
if is_stroke {
@@ -557,7 +587,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
}
}
// Connected Components on Grid (Simple merging)
let mut rects = Vec::new();
let mut visited = vec![false; grid.len()];
@@ -565,7 +594,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
for gx in 0..grid_w {
let idx = (gy * grid_w + gx) as usize;
if grid[idx] && !visited[idx] {
// Start a new component
let mut min_gx = gx;
let mut max_gx = gx;
let mut min_gy = gy;
@@ -580,7 +608,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
if cy < min_gy { min_gy = cy; }
if cy > max_gy { max_gy = cy; }
// Neighbors
let neighbors = [
(cx.wrapping_sub(1), cy), (cx + 1, cy),
(cx, cy.wrapping_sub(1)), (cx, cy + 1)
@@ -597,8 +624,6 @@ async fn detect_watermark(path: String) -> Result<DetectionResult, String> {
}
}
// Convert grid rect to normalized image rect
// Add padding (1 cell)
let px = (min_gx * cell_size) as f64;
let py = (min_gy * cell_size) as f64;
let pw = ((max_gx - min_gx + 1) * cell_size) as f64;
@@ -646,12 +671,12 @@ enum MaskStroke {
}
#[tauri::command]
async fn run_inpainting(path: String, strokes: Vec<MaskStroke>) -> Result<String, String> {
let img = image::open(&path).map_err(|e| e.to_string())?.to_rgba8();
async fn run_inpainting(app: AppHandle, path: String, strokes: Vec<MaskStroke>) -> Result<String, String> {
let img = load_image_safe(Path::new(&path))?.to_rgba8();
let (width, height) = img.dimensions();
// 1. Create Mask
let mut mask = vec![false; (width * height) as usize];
// 1. Create Gray Mask (0 = keep, 255 = remove)
let mut mask = image::GrayImage::new(width, height);
for stroke in strokes {
match stroke {
@@ -661,10 +686,11 @@ async fn run_inpainting(path: String, strokes: Vec<MaskStroke>) -> Result<String
let w = (rect.w * width as f64) as i32;
let h = (rect.h * height as f64) as i32;
// Draw 255 on mask
for y in y1..(y1 + h) {
for x in x1..(x1 + w) {
if x >= 0 && x < width as i32 && y >= 0 && y < height as i32 {
mask[(y as u32 * width + x as u32) as usize] = true;
mask.put_pixel(x as u32, y as u32, image::Luma([255]));
}
}
}
@@ -699,7 +725,7 @@ async fn run_inpainting(path: String, strokes: Vec<MaskStroke>) -> Result<String
let nx = cx + dx;
let ny = cy + dy;
if nx >= 0 && nx < width as i32 && ny >= 0 && ny < height as i32 {
mask[(ny as u32 * width + nx as u32) as usize] = true;
mask.put_pixel(nx as u32, ny as u32, image::Luma([255]));
}
}
}
@@ -710,76 +736,26 @@ async fn run_inpainting(path: String, strokes: Vec<MaskStroke>) -> Result<String
}
}
// 2. Diffusion Inpainting (Simple)
// Iteratively replace masked pixels with average of non-masked neighbors
// To make it converge, we update 'mask' as we go (treating filled pixels as valid source)
// But standard diffusion uses double buffering.
// For "Removing Text", simple inward filling works well.
// 2. Resolve Model Path
let model_path = app.path().resource_dir()
.map_err(|e| e.to_string())?
.join("resources")
.join("lama_fp32.onnx");
let iterations = 30;
let mut current_img = img.clone();
let mut next_img = img.clone();
// Convert mask to a distance map-like state?
// Or just simple neighbor average.
for _ in 0..iterations {
let mut changed = false;
for y in 0..height {
for x in 0..width {
let idx = (y * width + x) as usize;
if mask[idx] {
// It's a hole. Find valid neighbors.
// Valid = Not in ORIGINAL mask (so we pull from original image)
// OR processed in previous iteration?
// Simple logic: Pull from 'current_img'.
let mut sum_r = 0u32;
let mut sum_g = 0u32;
let mut sum_b = 0u32;
let mut count = 0;
// Check 4 neighbors
let neighbors = [
(x.wrapping_sub(1), y), (x + 1, y),
(x, y.wrapping_sub(1)), (x, y + 1)
];
for (nx, ny) in neighbors {
if nx < width && ny < height {
// Weighted check: If neighbor is ALSO masked, it contributes less?
// Or just take everything.
let pixel = current_img.get_pixel(nx, ny);
sum_r += pixel[0] as u32;
sum_g += pixel[1] as u32;
sum_b += pixel[2] as u32;
count += 1;
}
if !model_path.exists() {
return Err("Model file 'lama_fp32.onnx' not found in resources.".to_string());
}
if count > 0 {
let avg = image::Rgba([
(sum_r / count) as u8,
(sum_g / count) as u8,
(sum_b / count) as u8,
255
]);
next_img.put_pixel(x, y, avg);
changed = true;
}
}
}
}
current_img = next_img.clone();
if !changed { break; }
}
// 3. Run Inference
// This is computationally heavy, maybe run in thread? Tauri async commands are already threaded.
let result_img = lama::run_lama_inpainting(&model_path, &img, &mask)?;
// Save to temp
let cache_dir = get_cache_dir();
let file_name = format!("inpainted_{}.png", std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).unwrap().as_millis());
let out_path = cache_dir.join(file_name);
current_img.save(&out_path).map_err(|e| e.to_string())?;
result_img.save(&out_path).map_err(|e| e.to_string())?;
Ok(out_path.to_string_lossy().to_string())
}

202
src-tauri/src/ocr.rs Normal file
View File

@@ -0,0 +1,202 @@
use crate::ort_ops;
use image::{GenericImageView, Rgba, RgbaImage};
use ort::value::Value;
use std::path::Path;
#[derive(Clone, Debug)]
pub struct DetectedBox {
pub x: f64,
pub y: f64,
pub width: f64,
pub height: f64,
}
pub fn run_ocr_detection(
model_path: &Path,
input_image: &RgbaImage,
) -> Result<Vec<DetectedBox>, String> {
// 1. Load Model using the shared function
let mut session = ort_ops::create_session(model_path)
.map_err(|e| format!("Failed to create ORT session: {}", e))?;
// 2. Preprocess
// DBNet expects standard normalization: (img - mean) / std
// Mean: [0.485, 0.456, 0.406], Std: [0.229, 0.224, 0.225]
// And usually resized to multiple of 32. Limit max size for speed.
let max_side = 1600; // Increase resolution limit
let (orig_w, orig_h) = input_image.dimensions();
// --- CROP to top 5% and bottom 5% ---
let crop_height = (orig_h as f64 * 0.05).ceil() as u32;
let mut masked_image = RgbaImage::from_pixel(orig_w, orig_h, Rgba([0, 0, 0, 255]));
if crop_height > 0 {
// Copy top part
let top_view = input_image.view(0, 0, orig_w, crop_height).to_image();
image::imageops::replace(&mut masked_image, &top_view, 0, 0);
// Copy bottom part
let bottom_y = orig_h.saturating_sub(crop_height);
let bottom_view = input_image.view(0, bottom_y, orig_w, crop_height).to_image();
image::imageops::replace(&mut masked_image, &bottom_view, 0, bottom_y as i64);
} else {
// If image is very short, just use the original
masked_image = input_image.clone();
}
// --- End CROP ---
let mut resize_w = orig_w;
let mut resize_h = orig_h;
// Resize logic: Limit max side, preserve aspect, ensure divisible by 32
if resize_w > max_side || resize_h > max_side {
let ratio = max_side as f64 / (orig_w.max(orig_h) as f64);
resize_w = (orig_w as f64 * ratio) as u32;
resize_h = (orig_h as f64 * ratio) as u32;
}
// Align to 32
resize_w = (resize_w + 31) / 32 * 32;
resize_h = (resize_h + 31) / 32 * 32;
// Minimum size
resize_w = resize_w.max(32);
resize_h = resize_h.max(32);
let resized = image::imageops::resize(&masked_image, resize_w, resize_h, image::imageops::FilterType::Triangle);
let channel_stride = (resize_w * resize_h) as usize;
let mut input_data = Vec::with_capacity(1 * 3 * channel_stride);
let mut r_plane = Vec::with_capacity(channel_stride);
let mut g_plane = Vec::with_capacity(channel_stride);
let mut b_plane = Vec::with_capacity(channel_stride);
let mean = [0.485, 0.456, 0.406];
let std = [0.229, 0.224, 0.225];
for (_x, _y, pixel) in resized.enumerate_pixels() {
let r = pixel[0] as f32 / 255.0;
let g = pixel[1] as f32 / 255.0;
let b = pixel[2] as f32 / 255.0;
r_plane.push((r - mean[0]) / std[0]);
g_plane.push((g - mean[1]) / std[1]);
b_plane.push((b - mean[2]) / std[2]);
}
input_data.extend(r_plane);
input_data.extend(g_plane);
input_data.extend(b_plane);
// 3. Inference
let input_shape = vec![1, 3, resize_h as i64, resize_w as i64];
let input_value = Value::from_array((input_shape, input_data))
.map_err(|e| format!("Failed to create input tensor: {}", e))?;
let inputs = ort::inputs![input_value]; // For PP-OCR, usually just one input "x"
let outputs = session.run(inputs).map_err(|e| format!("Inference failed: {}", e))?;
let output_tensor = outputs.values().next().ok_or("No output")?;
let (shape, data) = output_tensor.try_extract_tensor::<f32>()
.map_err(|e| format!("Failed to extract output: {}", e))?;
// 4. Post-process
// Output shape is [1, 1, H, W] probability map
if shape.len() < 4 {
return Err("Unexpected output shape".to_string());
}
let map_w = shape[3] as u32;
let map_h = shape[2] as u32;
// Create binary map (threshold 0.3)
let threshold = 0.3;
let mut binary_map = vec![false; (map_w * map_h) as usize];
for i in 0..binary_map.len() {
if data[i] > threshold {
binary_map[i] = true;
}
}
// Find Connected Components (Simple Bounding Box finding)
let mut visited = vec![false; binary_map.len()];
let mut boxes = Vec::new();
for y in 0..map_h {
for x in 0..map_w {
let idx = (y * map_w + x) as usize;
if binary_map[idx] && !visited[idx] {
// Flood fill
let mut stack = vec![(x as u32, y as u32)];
visited[idx] = true;
let mut min_x = x;
let mut max_x = x;
let mut min_y = y;
let mut max_y = y;
let mut pixel_count = 0;
while let Some((cx, cy)) = stack.pop() {
pixel_count += 1;
if cx < min_x { min_x = cx; }
if cx > max_x { max_x = cx; }
if cy < min_y { min_y = cy; }
if cy > max_y { max_y = cy; }
let neighbors = [
(cx.wrapping_sub(1), cy), (cx + 1, cy),
(cx, cy.wrapping_sub(1)), (cx, cy + 1)
];
for (nx, ny) in neighbors {
if nx < map_w && ny < map_h {
let nidx = (ny * map_w + nx) as usize;
if binary_map[nidx] && !visited[nidx] {
visited[nidx] = true;
stack.push((nx, ny));
}
}
}
}
// Filter small noise
if pixel_count < 10 { continue; }
// Calculate Scale Factors
let scale_x = orig_w as f64 / resize_w as f64;
let scale_y = orig_h as f64 / resize_h as f64;
// Map to raw coordinates in map space
let raw_w = (max_x - min_x + 1) as f64;
let raw_h = (max_y - min_y + 1) as f64;
// --- ASPECT RATIO FILTERING ---
// Watermarks are typically horizontal text lines.
// A cross or vertical pillar will have a small width/height ratio.
let aspect_ratio = raw_w / raw_h;
if aspect_ratio < 1.5 {
continue; // Skip vertical or square-ish non-text objects
}
// --- PADDING / DILATION ---
let pad_x = raw_w * 0.15; // 15% horizontal is usually enough
let pad_y = raw_h * 1.00; // Increased to 100% for aggressive vertical coverage
let box_x = (min_x as f64 - pad_x).max(0.0);
let box_y = (min_y as f64 - pad_y).max(0.0);
let box_w = raw_w + 2.0 * pad_x;
let box_h = raw_h + 2.0 * pad_y;
// Convert to Normalized Image Coordinates [0, 1]
boxes.push(DetectedBox {
x: (box_x * scale_x) / orig_w as f64,
y: (box_y * scale_y) / orig_h as f64,
width: (box_w * scale_x) / orig_w as f64,
height: (box_h * scale_y) / orig_h as f64,
});
}
}
}
Ok(boxes)
}

41
src-tauri/src/ort_ops.rs Normal file
View File

@@ -0,0 +1,41 @@
use ort::session::{Session, builder::GraphOptimizationLevel};
use ort::execution_providers::DirectMLExecutionProvider;
use std::path::Path;
/// Attempts to create an ORT session with GPU (DirectML) acceleration.
/// If GPU initialization fails, it falls back to a CPU-only session.
pub fn create_session(model_path: &Path) -> Result<Session, ort::Error> {
// Try to build with DirectML
let dm_provider = DirectMLExecutionProvider::default().build();
let session_builder = Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.with_intra_threads(4)?;
match session_builder.with_execution_providers([dm_provider]) {
Ok(builder_with_dm) => {
println!("Attempting to commit session with DirectML provider...");
match builder_with_dm.commit_from_file(model_path) {
Ok(session) => {
println!("Successfully created ORT session with DirectML GPU acceleration.");
return Ok(session);
},
Err(e) => {
println!("Failed to create session with DirectML: {:?}. Falling back to CPU.", e);
// Fall through to CPU execution
}
}
},
Err(e) => {
println!("Failed to build session with DirectML provider: {:?}. Falling back to CPU.", e);
// Fall through to CPU execution
}
};
// Fallback to CPU
println!("Creating ORT session with CPU provider.");
Session::builder()?
.with_optimization_level(GraphOptimizationLevel::Level3)?
.with_intra_threads(4)?
.commit_from_file(model_path)
}

View File

@@ -1,7 +1,7 @@
{
"$schema": "https://schema.tauri.app/config/2",
"productName": "watermark-wizard",
"version": "0.1.0",
"version": "1.0.1",
"identifier": "top.volan.watermark-wizard",
"build": {
"beforeDevCommand": "pnpm dev",
@@ -12,7 +12,7 @@
"app": {
"windows": [
{
"title": "水印精灵 v0.1.0",
"title": "水印精灵 v1.0.1",
"width": 1650,
"height": 1000
}
@@ -28,6 +28,7 @@
"bundle": {
"active": true,
"targets": "all",
"resources": ["resources/lama_fp32.onnx", "resources/en_PP-OCRv3_det_infer.onnx"],
"icon": [
"icons/32x32.png",
"icons/128x128.png",

View File

@@ -6,11 +6,27 @@ import { useGalleryStore } from "./stores/gallery";
import { open } from '@tauri-apps/plugin-dialog';
import { invoke } from '@tauri-apps/api/core';
import { FolderOpen, Download } from 'lucide-vue-next';
import { ref } from "vue";
import { ref, onMounted, onUnmounted } from "vue";
const store = useGalleryStore();
const isExporting = ref(false);
function handleKeydown(event: KeyboardEvent) {
if (event.key === 'ArrowRight') {
store.nextImage();
} else if (event.key === 'ArrowLeft') {
store.prevImage();
}
}
onMounted(() => {
window.addEventListener('keydown', handleKeydown);
});
onUnmounted(() => {
window.removeEventListener('keydown', handleKeydown);
});
async function openFolder() {
try {
const selected = await open({
@@ -32,7 +48,9 @@ async function openFolder() {
async function exportBatch() {
if (store.images.length === 0) return;
if (!store.watermarkSettings.text) {
// Only require text if in ADD mode
if (store.editMode === 'add' && !store.watermarkSettings.text) {
alert("请输入水印文字。");
return;
}
@@ -48,13 +66,20 @@ async function exportBatch() {
isExporting.value = true;
// Map images to include manual settings
const exportTasks = store.images.map(img => ({
const exportTasks = store.images.map(img => {
// Extract filename from originalPath to ensure export uses original name
// Handles both Windows (\) and Unix (/) separators
const originalName = img.originalPath.split(/[/\\]/).pop() || "image.png";
return {
path: img.path,
output_filename: originalName,
manual_position: img.manualPosition || null,
scale: img.scale || null,
opacity: img.opacity || null,
color: img.color || null
}));
};
});
// Pass dummy globals for rust struct compatibility
// The backend struct fields are named _manual_override and _manual_position
@@ -67,7 +92,8 @@ async function exportBatch() {
await invoke('export_batch', {
images: exportTasks,
watermark: rustWatermarkSettings,
outputDir: outputDir
outputDir: outputDir,
mode: store.editMode
});
alert("批量导出完成!");
}
@@ -84,7 +110,7 @@ async function exportBatch() {
<div class="h-screen w-screen bg-gray-900 text-white overflow-hidden flex flex-col">
<header class="h-14 bg-gray-800 flex items-center justify-between px-6 border-b border-gray-700 shrink-0 shadow-md z-10">
<div class="flex items-center gap-4">
<h1 class="text-lg font-bold tracking-wider bg-gradient-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">水印精灵</h1>
<h1 class="text-lg font-bold tracking-wider bg-linear-to-r from-blue-400 to-purple-500 bg-clip-text text-transparent">水印精灵</h1>
</div>
<div class="flex items-center gap-3">

View File

@@ -1,6 +1,6 @@
<script setup lang="ts">
import { useGalleryStore } from "../stores/gallery";
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2, RotateCw } from 'lucide-vue-next';
import { Settings, CheckSquare, Type, Palette, Copy, Eraser, PlusSquare, Brush, Sparkles, Trash2, RotateCw, RotateCcw } from 'lucide-vue-next';
import { computed } from "vue";
const store = useGalleryStore();
@@ -181,22 +181,37 @@ const applyAll = () => {
</h2>
<!-- Auto Detect Controls -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">自动检测水印</label>
<div class="flex gap-2">
<button
@click="store.detectAllWatermarks()"
class="flex-1 bg-blue-600 hover:bg-blue-500 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
@click="store.detectCurrentWatermark()"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
:disabled="store.isDetecting || store.selectedIndex < 0"
>
<Sparkles class="w-4 h-4" /> 自动检测
<div v-if="store.isDetecting" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Sparkles v-else class="w-4 h-4" />
当前
</button>
<button
@click="store.detectAllWatermarks()"
class="flex-1 bg-gray-700 hover:bg-gray-600 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors"
:disabled="store.isDetecting || store.images.length === 0"
>
<div v-if="store.isDetecting" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Sparkles v-else class="w-4 h-4" />
全部
</button>
<button
@click="store.selectedIndex >= 0 && store.clearMask(store.selectedIndex)"
class="bg-gray-700 hover:bg-gray-600 text-gray-300 px-3 rounded flex items-center justify-center transition-colors"
title="清空遮罩"
title="清空当前遮罩"
:disabled="store.selectedIndex < 0"
>
<Trash2 class="w-4 h-4" />
</button>
</div>
</div>
<p class="text-xs text-gray-400">涂抹想要移除的水印AI 将自动填充背景</p>
@@ -215,17 +230,49 @@ const applyAll = () => {
/>
</div>
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200">
AI 修复功能已就绪
<div class="p-3 bg-red-900/20 border border-red-900/50 rounded text-xs text-red-200 flex flex-col gap-1">
<span>AI 修复功能已就绪</span>
<span v-if="store.isProcessing" class="text-yellow-300 animate-pulse">
正在处理: {{ store.progress.current }} / {{ store.progress.total }}
</span>
<span v-if="store.isDetecting" class="text-blue-300 animate-pulse">
正在检测: {{ store.progress.current }} / {{ store.progress.total }}
</span>
</div>
<!-- Execution Controls -->
<div class="flex flex-col gap-2">
<label class="text-sm text-gray-400 uppercase tracking-wider font-semibold">执行移除</label>
<div class="flex gap-2">
<button
@click="store.processInpainting(store.selectedIndex)"
class="flex-1 bg-red-600 hover:bg-red-500 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors shadow-lg"
:disabled="store.isProcessing || store.selectedIndex < 0"
>
<div v-if="store.isProcessing" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Eraser v-else class="w-4 h-4" />
当前
</button>
<button
@click="store.processAllInpainting()"
class="flex-1 bg-red-800 hover:bg-red-700 text-white py-2 rounded flex items-center justify-center gap-2 text-sm transition-colors shadow"
:disabled="store.isProcessing || store.images.length === 0"
>
<div v-if="store.isProcessing" class="w-3 h-3 border-2 border-white/30 border-t-white rounded-full animate-spin"></div>
<Eraser v-else class="w-4 h-4" />
全部
</button>
</div>
</div>
<button
@click="store.selectedIndex >= 0 && store.processInpainting(store.selectedIndex)"
class="w-full bg-red-600 hover:bg-red-500 text-white py-3 rounded font-bold shadow-lg transition-colors flex items-center justify-center gap-2"
:disabled="store.selectedIndex < 0"
v-if="store.selectedImage && store.selectedImage.path !== store.selectedImage.originalPath"
@click="store.selectedIndex >= 0 && store.restoreImage(store.selectedIndex)"
class="w-full bg-gray-700 hover:bg-gray-600 text-gray-300 py-2 rounded text-sm transition-colors flex items-center justify-center gap-2"
>
<Eraser class="w-5 h-5" />
执行移除
<RotateCcw class="w-4 h-4" />
还原原图
</button>
</div>

View File

@@ -23,7 +23,7 @@ const onSelect = (index: number) => {
>
<template #default="{ item, index }">
<div
class="h-full w-[100px] p-2 cursor-pointer transition-colors"
class="h-full w-25 p-2 cursor-pointer transition-colors"
:class="{'bg-blue-600': store.selectedIndex === index, 'hover:bg-gray-700': store.selectedIndex !== index}"
@click="onSelect(index)"
>

View File

@@ -11,6 +11,7 @@ export interface MaskStroke {
export interface ImageItem {
path: string;
originalPath: string;
thumbnail: string;
name: string;
width?: number;
@@ -51,6 +52,10 @@ export const useGalleryStore = defineStore("gallery", () => {
opacity: 0.5 // visual only
});
const isDetecting = ref(false);
const isProcessing = ref(false);
const progress = ref({ current: 0, total: 0 });
const selectedImage = computed(() => {
if (selectedIndex.value >= 0 && selectedIndex.value < images.value.length) {
return images.value[selectedIndex.value];
@@ -59,10 +64,22 @@ export const useGalleryStore = defineStore("gallery", () => {
});
function setImages(newImages: ImageItem[]) {
images.value = newImages;
images.value = newImages.map(img => ({
...img,
// Ensure originalPath is set if not already present from backend
originalPath: img.originalPath || img.path
}));
selectedIndex.value = -1;
}
function restoreImage(index: number) {
const img = images.value[index];
if (img) {
img.path = img.originalPath;
img.maskStrokes = []; // Also clear any masks if restoring
}
}
function updateWatermarkSettings(settings: Partial<WatermarkSettings>) {
watermarkSettings.value = { ...watermarkSettings.value, ...settings };
}
@@ -95,19 +112,16 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
async function detectAllWatermarks() {
if (images.value.length === 0) return;
const batchSize = 5;
for (let i = 0; i < images.value.length; i += batchSize) {
const batch = images.value.slice(i, i + batchSize).map(async (img) => {
// Helper to run detection on a single image object
async function detectWatermarkForImage(img: ImageItem) {
try {
// Clear existing mask first
img.maskStrokes = [];
// @ts-ignore
const result = await invoke<{rects: {x: number, y: number, width: number, height: number}[]}>("detect_watermark", { path: img.path });
if (result.rects && result.rects.length > 0) {
if (!img.maskStrokes) img.maskStrokes = [];
result.rects.forEach(r => {
img.maskStrokes!.push({
type: 'rect',
@@ -118,9 +132,37 @@ export const useGalleryStore = defineStore("gallery", () => {
} catch (e) {
console.error(`Detection failed for ${img.name}`, e);
}
}
async function detectCurrentWatermark() {
if (selectedIndex.value < 0 || !images.value[selectedIndex.value]) return;
isDetecting.value = true;
progress.value = { current: 0, total: 1 };
try {
await detectWatermarkForImage(images.value[selectedIndex.value]);
progress.value.current = 1;
} finally {
isDetecting.value = false;
}
}
async function detectAllWatermarks() {
if (images.value.length === 0) return;
isDetecting.value = true;
progress.value = { current: 0, total: images.value.length };
try {
const batchSize = 5;
for (let i = 0; i < images.value.length; i += batchSize) {
const batch = images.value.slice(i, i + batchSize).map(async (img) => {
await detectWatermarkForImage(img);
progress.value.current++;
});
await Promise.all(batch);
}
} finally {
isDetecting.value = false;
}
}
async function recalcAllWatermarks() {
@@ -140,9 +182,6 @@ export const useGalleryStore = defineStore("gallery", () => {
baseScale: baseScale
});
// Find index again just in case sort changed? No, we rely on reference or index.
// Img is reference. But setter needs index.
// Let's use image reference finding.
const idx = images.value.indexOf(img);
if (idx >= 0) {
setImageManualPosition(idx, result.x, result.y);
@@ -156,9 +195,9 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
async function processInpainting(index: number) {
const img = images.value[index];
if (!img || !img.maskStrokes || img.maskStrokes.length === 0) return;
// Internal helper for single image inpainting logic
async function runInpaintingForImage(img: ImageItem) {
if (!img.maskStrokes || img.maskStrokes.length === 0) return;
try {
const newPath = await invoke<string>("run_inpainting", {
@@ -166,20 +205,51 @@ export const useGalleryStore = defineStore("gallery", () => {
strokes: img.maskStrokes
});
// Update the image path to point to the processed version
// NOTE: This updates the "Source" of the image in the store.
// In a real app, maybe we keep original? But for "Wizard", modifying is the goal.
img.path = newPath;
// Clear mask after success
img.maskStrokes = [];
// Force UI refresh (thumbnail) - might be needed
// Generate new thumb?
// Since path changed, converting src should trigger reload.
} catch (e) {
console.error("Inpainting failed", e);
alert("Inpainting failed: " + e);
throw e; // Propagate error
}
}
async function processInpainting(index: number) {
const img = images.value[index];
if (!img) return;
isProcessing.value = true;
progress.value = { current: 0, total: 1 };
try {
await runInpaintingForImage(img);
progress.value.current = 1;
} catch (e) {
alert("处理失败: " + e);
} finally {
isProcessing.value = false;
}
}
async function processAllInpainting() {
const candidates = images.value.filter(img => img.maskStrokes && img.maskStrokes.length > 0);
if (candidates.length === 0) return;
isProcessing.value = true;
progress.value = { current: 0, total: candidates.length };
try {
// Parallel processing in batches to avoid overwhelming backend
const batchSize = 4;
for (let i = 0; i < candidates.length; i += batchSize) {
const batch = candidates.slice(i, i + batchSize).map(async (img) => {
await runInpaintingForImage(img);
progress.value.current++;
});
await Promise.all(batch);
}
alert("批量处理完成!");
} catch (e) {
alert("批量处理部分失败: " + e);
} finally {
isProcessing.value = false;
}
}
@@ -221,6 +291,18 @@ export const useGalleryStore = defineStore("gallery", () => {
}
}
function nextImage() {
if (images.value.length === 0) return;
const nextIndex = (selectedIndex.value + 1) % images.value.length;
selectImage(nextIndex);
}
function prevImage() {
if (images.value.length === 0) return;
const prevIndex = (selectedIndex.value - 1 + images.value.length) % images.value.length;
selectImage(prevIndex);
}
return {
images,
selectedIndex,
@@ -228,6 +310,9 @@ export const useGalleryStore = defineStore("gallery", () => {
editMode,
watermarkSettings,
brushSettings,
isDetecting,
isProcessing,
progress,
setImages,
selectImage,
updateWatermarkSettings,
@@ -236,8 +321,13 @@ export const useGalleryStore = defineStore("gallery", () => {
applySettingsToAll,
addMaskStroke,
clearMask,
detectCurrentWatermark,
detectAllWatermarks,
recalcAllWatermarks,
processInpainting
processInpainting,
processAllInpainting,
restoreImage,
nextImage,
prevImage
};
});

View File

@@ -6,3 +6,23 @@
font-weight: normal;
font-style: normal;
}
/* Custom scrollbar styles */
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background-color: #1f2937; /* gray-800 */
border-radius: 10px;
}
::-webkit-scrollbar-thumb {
background-color: #4b5563; /* gray-600 */
border-radius: 10px;
}
::-webkit-scrollbar-thumb:hover {
background-color: #6b7280; /* gray-500 */
}