gemini-logo-remover 0.1.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- gemini_logo_remover-0.1.0/LICENSE +21 -0
- gemini_logo_remover-0.1.0/PKG-INFO +136 -0
- gemini_logo_remover-0.1.0/README.md +111 -0
- gemini_logo_remover-0.1.0/pyproject.toml +43 -0
- gemini_logo_remover-0.1.0/setup.cfg +4 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover/__init__.py +27 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover/__main__.py +5 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover/cli.py +83 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover/client.py +278 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover/py.typed +0 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/PKG-INFO +136 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/SOURCES.txt +15 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/dependency_links.txt +1 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/entry_points.txt +2 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/requires.txt +7 -0
- gemini_logo_remover-0.1.0/src/gemini_logo_remover.egg-info/top_level.txt +1 -0
- gemini_logo_remover-0.1.0/tests/test_client.py +232 -0
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gemini-logo-remover contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gemini-logo-remover
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remove the Gemini watermark from images with one function call, using the Gemini Logo Remover API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://app.gemini-logo-remover.workers.dev
|
|
7
|
+
Project-URL: Documentation, https://app.gemini-logo-remover.workers.dev/developers
|
|
8
|
+
Keywords: gemini,watermark,logo,remover,image,ai
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests>=2.28
|
|
19
|
+
Requires-Dist: Pillow>=9.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# gemini-logo-remover
|
|
27
|
+
|
|
28
|
+
Remove the Gemini watermark from images with one function call.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install gemini-logo-remover
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from gemini_logo_remover import remove_logo
|
|
36
|
+
|
|
37
|
+
remove_logo("gemini-image.png", "clean.png")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
The watermark only sits in the bottom-right corner. This package cuts out that corner
|
|
43
|
+
(at most 480×480 px), sends **only that small piece** to the Gemini Logo Remover API,
|
|
44
|
+
and pastes the cleaned corner back into your original image on your machine.
|
|
45
|
+
|
|
46
|
+
- Uploads are tiny (a few hundred KB), so it is fast.
|
|
47
|
+
- Every pixel outside the corner is returned exactly as it was — the server never
|
|
48
|
+
re-compresses your picture.
|
|
49
|
+
- The removal itself runs on the hosted API (a Cloudflare Worker), not on your computer.
|
|
50
|
+
Your images are not stored; only the corner crop is sent.
|
|
51
|
+
|
|
52
|
+
The server reverses the watermark's alpha blend (lossless) and falls back to AI inpainting
|
|
53
|
+
only for what that cannot explain.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### One image
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from gemini_logo_remover import remove_logo
|
|
61
|
+
|
|
62
|
+
result = remove_logo("in.png", "out.png") # saves out.png
|
|
63
|
+
print(result.method, result.clean, result.warnings)
|
|
64
|
+
|
|
65
|
+
# no file written, work with the Pillow image instead
|
|
66
|
+
result = remove_logo("in.png")
|
|
67
|
+
result.image.show()
|
|
68
|
+
result.save("later.jpg") # .jpg -> JPEG, otherwise PNG
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`image` can be a path, `bytes`, an open binary file, or a `PIL.Image`.
|
|
72
|
+
|
|
73
|
+
`Result` fields: `image`, `method` (`alpha`, `inpaint`, `alpha+inpaint`, `none`),
|
|
74
|
+
`detected`, `slot`, `confidence`, `clean`, `warnings`, `output_path`.
|
|
75
|
+
|
|
76
|
+
### Many images, in parallel
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from gemini_logo_remover import remove_logo_many
|
|
80
|
+
|
|
81
|
+
items = remove_logo_many(["a.png", "b.png", "c.jpg"], "cleaned/", workers=3)
|
|
82
|
+
for item in items:
|
|
83
|
+
print(item.source, "ok" if item.ok else f"failed: {item.error}")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
One bad file never stops the others; failures are returned in the list. Keep `workers`
|
|
87
|
+
at 3 or less on the free hosted API.
|
|
88
|
+
|
|
89
|
+
### Command line
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
gemini-logo-remover photo.png # writes photo_clean.png next to it
|
|
93
|
+
gemini-logo-remover *.png -o cleaned/ --workers 3
|
|
94
|
+
gemini-logo-remover photo.png --mode alpha # lossless only
|
|
95
|
+
python -m gemini_logo_remover photo.png # same thing
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Options
|
|
99
|
+
|
|
100
|
+
| Option | Default | Meaning |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `mode` | `auto` | `auto`: reverse the watermark, inpaint only what is left. `alpha`: lossless only, never inpaints (image unchanged + a warning if it cannot). `inpaint`: skip alpha. |
|
|
103
|
+
| `sparkle` | `False` | Also inpaint the small V3 slot. Unverified; can damage text near the corner. |
|
|
104
|
+
| `api_url` | hosted API | Your own server. Or set `GEMINI_LOGO_REMOVER_API_URL`. |
|
|
105
|
+
| `api_key` | none | If the server requires a key. Or set `GEMINI_LOGO_REMOVER_API_KEY`. |
|
|
106
|
+
| `retries` | `3` | Extra attempts on 429 / 5xx / network errors / cut-off replies. |
|
|
107
|
+
| `timeout` | `180` | Seconds per request. |
|
|
108
|
+
|
|
109
|
+
### Errors
|
|
110
|
+
|
|
111
|
+
- `InvalidImageError` — the input is not a readable image.
|
|
112
|
+
- `ApiError` — the API rejected the request or kept failing. Has `.status` and `.code`.
|
|
113
|
+
- `GeminiLogoRemoverError` — base class of both.
|
|
114
|
+
|
|
115
|
+
## Limits
|
|
116
|
+
|
|
117
|
+
The hosted API runs on a free tier. Expect to process a few images at a time, not
|
|
118
|
+
thousands. Point `api_url` at your own deployment of the server for heavy use.
|
|
119
|
+
|
|
120
|
+
## Development and publishing
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cd python
|
|
124
|
+
pip install -e ".[dev]"
|
|
125
|
+
pytest
|
|
126
|
+
|
|
127
|
+
python -m build # creates dist/*.whl and dist/*.tar.gz
|
|
128
|
+
twine check dist/*
|
|
129
|
+
twine upload --repository testpypi dist/* # try it on TestPyPI first
|
|
130
|
+
twine upload dist/* # publish to PyPI
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Uploading needs a PyPI account and an API token (pypi.org → Account settings → API tokens);
|
|
134
|
+
use `__token__` as the username and the token as the password. Bump `version` in
|
|
135
|
+
`pyproject.toml` (and `__version__`) for every release — PyPI never lets you re-upload the
|
|
136
|
+
same version.
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
# gemini-logo-remover
|
|
2
|
+
|
|
3
|
+
Remove the Gemini watermark from images with one function call.
|
|
4
|
+
|
|
5
|
+
```bash
|
|
6
|
+
pip install gemini-logo-remover
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
```python
|
|
10
|
+
from gemini_logo_remover import remove_logo
|
|
11
|
+
|
|
12
|
+
remove_logo("gemini-image.png", "clean.png")
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
## How it works
|
|
16
|
+
|
|
17
|
+
The watermark only sits in the bottom-right corner. This package cuts out that corner
|
|
18
|
+
(at most 480×480 px), sends **only that small piece** to the Gemini Logo Remover API,
|
|
19
|
+
and pastes the cleaned corner back into your original image on your machine.
|
|
20
|
+
|
|
21
|
+
- Uploads are tiny (a few hundred KB), so it is fast.
|
|
22
|
+
- Every pixel outside the corner is returned exactly as it was — the server never
|
|
23
|
+
re-compresses your picture.
|
|
24
|
+
- The removal itself runs on the hosted API (a Cloudflare Worker), not on your computer.
|
|
25
|
+
Your images are not stored; only the corner crop is sent.
|
|
26
|
+
|
|
27
|
+
The server reverses the watermark's alpha blend (lossless) and falls back to AI inpainting
|
|
28
|
+
only for what that cannot explain.
|
|
29
|
+
|
|
30
|
+
## Usage
|
|
31
|
+
|
|
32
|
+
### One image
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from gemini_logo_remover import remove_logo
|
|
36
|
+
|
|
37
|
+
result = remove_logo("in.png", "out.png") # saves out.png
|
|
38
|
+
print(result.method, result.clean, result.warnings)
|
|
39
|
+
|
|
40
|
+
# no file written, work with the Pillow image instead
|
|
41
|
+
result = remove_logo("in.png")
|
|
42
|
+
result.image.show()
|
|
43
|
+
result.save("later.jpg") # .jpg -> JPEG, otherwise PNG
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
`image` can be a path, `bytes`, an open binary file, or a `PIL.Image`.
|
|
47
|
+
|
|
48
|
+
`Result` fields: `image`, `method` (`alpha`, `inpaint`, `alpha+inpaint`, `none`),
|
|
49
|
+
`detected`, `slot`, `confidence`, `clean`, `warnings`, `output_path`.
|
|
50
|
+
|
|
51
|
+
### Many images, in parallel
|
|
52
|
+
|
|
53
|
+
```python
|
|
54
|
+
from gemini_logo_remover import remove_logo_many
|
|
55
|
+
|
|
56
|
+
items = remove_logo_many(["a.png", "b.png", "c.jpg"], "cleaned/", workers=3)
|
|
57
|
+
for item in items:
|
|
58
|
+
print(item.source, "ok" if item.ok else f"failed: {item.error}")
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
One bad file never stops the others; failures are returned in the list. Keep `workers`
|
|
62
|
+
at 3 or less on the free hosted API.
|
|
63
|
+
|
|
64
|
+
### Command line
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
gemini-logo-remover photo.png # writes photo_clean.png next to it
|
|
68
|
+
gemini-logo-remover *.png -o cleaned/ --workers 3
|
|
69
|
+
gemini-logo-remover photo.png --mode alpha # lossless only
|
|
70
|
+
python -m gemini_logo_remover photo.png # same thing
|
|
71
|
+
```
|
|
72
|
+
|
|
73
|
+
### Options
|
|
74
|
+
|
|
75
|
+
| Option | Default | Meaning |
|
|
76
|
+
| --- | --- | --- |
|
|
77
|
+
| `mode` | `auto` | `auto`: reverse the watermark, inpaint only what is left. `alpha`: lossless only, never inpaints (image unchanged + a warning if it cannot). `inpaint`: skip alpha. |
|
|
78
|
+
| `sparkle` | `False` | Also inpaint the small V3 slot. Unverified; can damage text near the corner. |
|
|
79
|
+
| `api_url` | hosted API | Your own server. Or set `GEMINI_LOGO_REMOVER_API_URL`. |
|
|
80
|
+
| `api_key` | none | If the server requires a key. Or set `GEMINI_LOGO_REMOVER_API_KEY`. |
|
|
81
|
+
| `retries` | `3` | Extra attempts on 429 / 5xx / network errors / cut-off replies. |
|
|
82
|
+
| `timeout` | `180` | Seconds per request. |
|
|
83
|
+
|
|
84
|
+
### Errors
|
|
85
|
+
|
|
86
|
+
- `InvalidImageError` — the input is not a readable image.
|
|
87
|
+
- `ApiError` — the API rejected the request or kept failing. Has `.status` and `.code`.
|
|
88
|
+
- `GeminiLogoRemoverError` — base class of both.
|
|
89
|
+
|
|
90
|
+
## Limits
|
|
91
|
+
|
|
92
|
+
The hosted API runs on a free tier. Expect to process a few images at a time, not
|
|
93
|
+
thousands. Point `api_url` at your own deployment of the server for heavy use.
|
|
94
|
+
|
|
95
|
+
## Development and publishing
|
|
96
|
+
|
|
97
|
+
```bash
|
|
98
|
+
cd python
|
|
99
|
+
pip install -e ".[dev]"
|
|
100
|
+
pytest
|
|
101
|
+
|
|
102
|
+
python -m build # creates dist/*.whl and dist/*.tar.gz
|
|
103
|
+
twine check dist/*
|
|
104
|
+
twine upload --repository testpypi dist/* # try it on TestPyPI first
|
|
105
|
+
twine upload dist/* # publish to PyPI
|
|
106
|
+
```
|
|
107
|
+
|
|
108
|
+
Uploading needs a PyPI account and an API token (pypi.org → Account settings → API tokens);
|
|
109
|
+
use `__token__` as the username and the token as the password. Bump `version` in
|
|
110
|
+
`pyproject.toml` (and `__version__`) for every release — PyPI never lets you re-upload the
|
|
111
|
+
same version.
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=68", "wheel"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "gemini-logo-remover"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Remove the Gemini watermark from images with one function call, using the Gemini Logo Remover API."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "MIT" }
|
|
12
|
+
keywords = ["gemini", "watermark", "logo", "remover", "image", "ai"]
|
|
13
|
+
classifiers = [
|
|
14
|
+
"Development Status :: 4 - Beta",
|
|
15
|
+
"Intended Audience :: Developers",
|
|
16
|
+
"License :: OSI Approved :: MIT License",
|
|
17
|
+
"Programming Language :: Python :: 3",
|
|
18
|
+
"Topic :: Multimedia :: Graphics",
|
|
19
|
+
"Typing :: Typed",
|
|
20
|
+
]
|
|
21
|
+
dependencies = [
|
|
22
|
+
"requests>=2.28",
|
|
23
|
+
"Pillow>=9.0",
|
|
24
|
+
]
|
|
25
|
+
|
|
26
|
+
[project.optional-dependencies]
|
|
27
|
+
dev = ["pytest>=7", "build", "twine"]
|
|
28
|
+
|
|
29
|
+
[project.scripts]
|
|
30
|
+
gemini-logo-remover = "gemini_logo_remover.cli:main"
|
|
31
|
+
|
|
32
|
+
[project.urls]
|
|
33
|
+
Homepage = "https://app.gemini-logo-remover.workers.dev"
|
|
34
|
+
Documentation = "https://app.gemini-logo-remover.workers.dev/developers"
|
|
35
|
+
|
|
36
|
+
[tool.setuptools.packages.find]
|
|
37
|
+
where = ["src"]
|
|
38
|
+
|
|
39
|
+
[tool.setuptools.package-data]
|
|
40
|
+
gemini_logo_remover = ["py.typed"]
|
|
41
|
+
|
|
42
|
+
[tool.pytest.ini_options]
|
|
43
|
+
testpaths = ["tests"]
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
"""Remove the Gemini watermark from images.
|
|
2
|
+
|
|
3
|
+
from gemini_logo_remover import remove_logo
|
|
4
|
+
remove_logo("in.png", "out.png")
|
|
5
|
+
"""
|
|
6
|
+
from .client import (
|
|
7
|
+
DEFAULT_API_URL,
|
|
8
|
+
ApiError,
|
|
9
|
+
BatchItem,
|
|
10
|
+
GeminiLogoRemoverError,
|
|
11
|
+
InvalidImageError,
|
|
12
|
+
Result,
|
|
13
|
+
remove_logo,
|
|
14
|
+
remove_logo_many,
|
|
15
|
+
)
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"DEFAULT_API_URL",
|
|
19
|
+
"ApiError",
|
|
20
|
+
"BatchItem",
|
|
21
|
+
"GeminiLogoRemoverError",
|
|
22
|
+
"InvalidImageError",
|
|
23
|
+
"Result",
|
|
24
|
+
"remove_logo",
|
|
25
|
+
"remove_logo_many",
|
|
26
|
+
]
|
|
27
|
+
__version__ = "0.1.0"
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
"""Command line: ``gemini-logo-remover photo.png`` (or ``python -m gemini_logo_remover``)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import List, Optional
|
|
8
|
+
|
|
9
|
+
from . import __version__
|
|
10
|
+
from .client import (
|
|
11
|
+
DEFAULT_API_URL,
|
|
12
|
+
ENV_API_KEY,
|
|
13
|
+
ENV_API_URL,
|
|
14
|
+
BatchItem,
|
|
15
|
+
remove_logo_many,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _parser() -> argparse.ArgumentParser:
|
|
20
|
+
p = argparse.ArgumentParser(
|
|
21
|
+
prog="gemini-logo-remover",
|
|
22
|
+
description="Remove the Gemini watermark from images using the Gemini Logo Remover API.",
|
|
23
|
+
)
|
|
24
|
+
p.add_argument("images", nargs="+", type=Path, help="one or more image files (PNG/JPEG/WebP)")
|
|
25
|
+
p.add_argument(
|
|
26
|
+
"-o", "--output-dir", type=Path, default=None,
|
|
27
|
+
help="where to write results (default: next to each input, as <name>_clean.png)",
|
|
28
|
+
)
|
|
29
|
+
p.add_argument("--mode", choices=("auto", "alpha", "inpaint"), default="auto",
|
|
30
|
+
help="auto: alpha blend then inpaint only what is left (default); alpha: lossless only")
|
|
31
|
+
p.add_argument("--format", choices=("png", "jpg"), default="png", help="output format (default: png)")
|
|
32
|
+
p.add_argument("--sparkle", action="store_true", help="also inpaint the small V3 slot (can damage text)")
|
|
33
|
+
p.add_argument("--workers", type=int, default=3, help="images processed in parallel (default: 3)")
|
|
34
|
+
p.add_argument("--api-url", default=None, help=f"API endpoint (default: ${ENV_API_URL} or {DEFAULT_API_URL})")
|
|
35
|
+
p.add_argument("--api-key", default=None, help=f"API key (default: ${ENV_API_KEY})")
|
|
36
|
+
p.add_argument("--version", action="version", version=f"%(prog)s {__version__}")
|
|
37
|
+
return p
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def main(argv: Optional[List[str]] = None) -> int:
|
|
41
|
+
args = _parser().parse_args(argv)
|
|
42
|
+
ext = ".jpg" if args.format == "jpg" else ".png"
|
|
43
|
+
|
|
44
|
+
failures = 0
|
|
45
|
+
|
|
46
|
+
def report(item: BatchItem) -> None:
|
|
47
|
+
nonlocal failures
|
|
48
|
+
if item.error is not None:
|
|
49
|
+
failures += 1
|
|
50
|
+
print(f"FAILED {item.source}: {item.error}", file=sys.stderr)
|
|
51
|
+
return
|
|
52
|
+
r = item.result
|
|
53
|
+
assert r is not None
|
|
54
|
+
extra = f" ({'; '.join(r.warnings)})" if r.warnings else ""
|
|
55
|
+
state = "clean" if r.clean else "residue left"
|
|
56
|
+
print(f"done {item.source} -> {r.output_path} [{r.method}, {state}]{extra}")
|
|
57
|
+
|
|
58
|
+
# Group by output directory so each file lands where the user asked.
|
|
59
|
+
groups: dict = {}
|
|
60
|
+
for path in args.images:
|
|
61
|
+
out_dir = args.output_dir or path.resolve().parent
|
|
62
|
+
groups.setdefault(out_dir, []).append(path)
|
|
63
|
+
|
|
64
|
+
for out_dir, paths in groups.items():
|
|
65
|
+
remove_logo_many(
|
|
66
|
+
paths,
|
|
67
|
+
out_dir,
|
|
68
|
+
workers=args.workers,
|
|
69
|
+
ext=ext,
|
|
70
|
+
on_done=report,
|
|
71
|
+
mode=args.mode,
|
|
72
|
+
sparkle=args.sparkle,
|
|
73
|
+
api_url=args.api_url,
|
|
74
|
+
api_key=args.api_key,
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
total = len(args.images)
|
|
78
|
+
print(f"{total - failures} succeeded, {failures} failed")
|
|
79
|
+
return 1 if failures else 0
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
if __name__ == "__main__":
|
|
83
|
+
sys.exit(main())
|
|
@@ -0,0 +1,278 @@
|
|
|
1
|
+
"""Client for the Gemini Logo Remover API.
|
|
2
|
+
|
|
3
|
+
The watermark only ever sits in the bottom-right corner, so instead of uploading
|
|
4
|
+
the whole picture this crops that corner (at most 480x480 px), sends the small
|
|
5
|
+
crop to the API, and pastes the cleaned corner back into the original on your
|
|
6
|
+
machine. Uploads are tiny and every pixel outside the corner is returned exactly
|
|
7
|
+
as it was.
|
|
8
|
+
"""
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import io
|
|
12
|
+
import os
|
|
13
|
+
import time
|
|
14
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
15
|
+
from dataclasses import dataclass, field
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import BinaryIO, Callable, Iterable, List, Optional, Sequence, Tuple, Union
|
|
18
|
+
|
|
19
|
+
import requests
|
|
20
|
+
from PIL import Image, ImageOps
|
|
21
|
+
|
|
22
|
+
DEFAULT_API_URL = "https://app.gemini-logo-remover.workers.dev/api/v1/remove"
|
|
23
|
+
ENV_API_URL = "GEMINI_LOGO_REMOVER_API_URL"
|
|
24
|
+
ENV_API_KEY = "GEMINI_LOGO_REMOVER_API_KEY"
|
|
25
|
+
|
|
26
|
+
#: Corner size in px. Must match the server (it validates the crop dimensions).
|
|
27
|
+
WINDOW = 480
|
|
28
|
+
|
|
29
|
+
_RETRY_STATUS = {429, 500, 502, 503, 504}
|
|
30
|
+
_MODES = ("auto", "alpha", "inpaint")
|
|
31
|
+
|
|
32
|
+
ImageInput = Union[str, "os.PathLike[str]", bytes, bytearray, BinaryIO, Image.Image]
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class GeminiLogoRemoverError(Exception):
|
|
36
|
+
"""Base class for all errors raised by this package."""
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class ApiError(GeminiLogoRemoverError):
|
|
40
|
+
"""The API answered with an error (or kept failing after all retries)."""
|
|
41
|
+
|
|
42
|
+
def __init__(self, message: str, status: Optional[int] = None, code: Optional[str] = None):
|
|
43
|
+
super().__init__(message)
|
|
44
|
+
self.status = status
|
|
45
|
+
self.code = code
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class InvalidImageError(GeminiLogoRemoverError):
|
|
49
|
+
"""The input could not be read as an image."""
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
@dataclass
|
|
53
|
+
class Result:
|
|
54
|
+
"""Outcome of one removal."""
|
|
55
|
+
|
|
56
|
+
image: Image.Image
|
|
57
|
+
#: "alpha" (lossless), "inpaint", "alpha+inpaint" or "none" (nothing changed).
|
|
58
|
+
method: str = "none"
|
|
59
|
+
detected: bool = False
|
|
60
|
+
slot: Optional[str] = None
|
|
61
|
+
confidence: Optional[float] = None
|
|
62
|
+
#: True when no watermark signal is left in the cleaned slot.
|
|
63
|
+
clean: bool = True
|
|
64
|
+
warnings: List[str] = field(default_factory=list)
|
|
65
|
+
output_path: Optional[Path] = None
|
|
66
|
+
|
|
67
|
+
def save(self, path: Union[str, "os.PathLike[str]"], quality: int = 95) -> Path:
|
|
68
|
+
"""Save the cleaned image. ``.jpg`` / ``.jpeg`` -> JPEG, anything else -> PNG."""
|
|
69
|
+
out = Path(path)
|
|
70
|
+
out.parent.mkdir(parents=True, exist_ok=True)
|
|
71
|
+
if out.suffix.lower() in (".jpg", ".jpeg"):
|
|
72
|
+
self.image.convert("RGB").save(out, "JPEG", quality=quality, subsampling=0)
|
|
73
|
+
else:
|
|
74
|
+
self.image.save(out, "PNG")
|
|
75
|
+
self.output_path = out
|
|
76
|
+
return out
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def _open_image(source: ImageInput) -> Tuple[Image.Image, Optional[str]]:
|
|
80
|
+
"""Return (RGB/RGBA image with EXIF rotation applied, original format)."""
|
|
81
|
+
try:
|
|
82
|
+
if isinstance(source, Image.Image):
|
|
83
|
+
img, fmt = source, source.format
|
|
84
|
+
elif isinstance(source, (bytes, bytearray)):
|
|
85
|
+
img = Image.open(io.BytesIO(bytes(source)))
|
|
86
|
+
fmt = img.format
|
|
87
|
+
elif hasattr(source, "read"):
|
|
88
|
+
img = Image.open(io.BytesIO(source.read())) # type: ignore[union-attr]
|
|
89
|
+
fmt = img.format
|
|
90
|
+
else:
|
|
91
|
+
img = Image.open(source) # type: ignore[arg-type]
|
|
92
|
+
fmt = img.format
|
|
93
|
+
img.load()
|
|
94
|
+
except (OSError, ValueError) as exc:
|
|
95
|
+
raise InvalidImageError(f"Could not read the image: {exc}") from exc
|
|
96
|
+
|
|
97
|
+
img = ImageOps.exif_transpose(img)
|
|
98
|
+
if img.mode not in ("RGB", "RGBA"):
|
|
99
|
+
img = img.convert("RGB")
|
|
100
|
+
return img, fmt
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def _headers(response: requests.Response) -> dict:
|
|
104
|
+
return {k.lower(): v for k, v in response.headers.items() if k.lower().startswith("x-")}
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def _post_crop(
|
|
108
|
+
crop_png: bytes,
|
|
109
|
+
full_size: Tuple[int, int],
|
|
110
|
+
*,
|
|
111
|
+
api_url: str,
|
|
112
|
+
api_key: Optional[str],
|
|
113
|
+
mode: str,
|
|
114
|
+
sparkle: bool,
|
|
115
|
+
timeout: float,
|
|
116
|
+
retries: int,
|
|
117
|
+
session: Optional[requests.Session],
|
|
118
|
+
) -> Tuple[Image.Image, dict]:
|
|
119
|
+
"""Send the corner crop; retry transient failures. Returns (cleaned crop, headers)."""
|
|
120
|
+
http = session or requests
|
|
121
|
+
headers = {"X-API-Key": api_key} if api_key else {}
|
|
122
|
+
params = {
|
|
123
|
+
"mode": mode,
|
|
124
|
+
"format": "png",
|
|
125
|
+
"sparkle": "true" if sparkle else "false",
|
|
126
|
+
"full_size": f"{full_size[0]}x{full_size[1]}",
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
last = "unknown error"
|
|
130
|
+
last_status: Optional[int] = None
|
|
131
|
+
last_code: Optional[str] = None
|
|
132
|
+
for attempt in range(retries + 1):
|
|
133
|
+
try:
|
|
134
|
+
response = http.post(
|
|
135
|
+
api_url,
|
|
136
|
+
params=params,
|
|
137
|
+
headers=headers,
|
|
138
|
+
files={"image": ("corner.png", crop_png, "image/png")},
|
|
139
|
+
timeout=timeout,
|
|
140
|
+
)
|
|
141
|
+
if response.status_code == 200:
|
|
142
|
+
patch = Image.open(io.BytesIO(response.content))
|
|
143
|
+
patch.load() # a cut-off body fails here and is retried
|
|
144
|
+
return patch, _headers(response)
|
|
145
|
+
|
|
146
|
+
last_status = response.status_code
|
|
147
|
+
try:
|
|
148
|
+
error = response.json().get("error", {})
|
|
149
|
+
last_code, last = error.get("code"), error.get("message", response.text[:200])
|
|
150
|
+
except ValueError:
|
|
151
|
+
last_code, last = None, response.text[:200] or f"HTTP {response.status_code}"
|
|
152
|
+
if response.status_code not in _RETRY_STATUS:
|
|
153
|
+
break
|
|
154
|
+
except (requests.RequestException, OSError, ValueError) as exc:
|
|
155
|
+
last_status, last_code, last = None, None, f"{type(exc).__name__}: {exc}"
|
|
156
|
+
if attempt < retries:
|
|
157
|
+
time.sleep(2 * (attempt + 1))
|
|
158
|
+
|
|
159
|
+
raise ApiError(f"Gemini Logo Remover API failed: {last}", status=last_status, code=last_code)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def remove_logo(
|
|
163
|
+
image: ImageInput,
|
|
164
|
+
output: Optional[Union[str, "os.PathLike[str]"]] = None,
|
|
165
|
+
*,
|
|
166
|
+
api_url: Optional[str] = None,
|
|
167
|
+
api_key: Optional[str] = None,
|
|
168
|
+
mode: str = "auto",
|
|
169
|
+
sparkle: bool = False,
|
|
170
|
+
timeout: float = 180.0,
|
|
171
|
+
retries: int = 3,
|
|
172
|
+
quality: int = 95,
|
|
173
|
+
session: Optional[requests.Session] = None,
|
|
174
|
+
) -> Result:
|
|
175
|
+
"""Remove the Gemini watermark from one image.
|
|
176
|
+
|
|
177
|
+
:param image: a file path, ``bytes``, an open binary file, or a ``PIL.Image``.
|
|
178
|
+
:param output: where to save the cleaned image (``.jpg`` -> JPEG, else PNG).
|
|
179
|
+
When omitted nothing is written; use ``result.image`` / ``result.save()``.
|
|
180
|
+
:param api_url: API endpoint. Default: ``$GEMINI_LOGO_REMOVER_API_URL`` or the hosted one.
|
|
181
|
+
:param api_key: API key, if the server requires one. Default: ``$GEMINI_LOGO_REMOVER_API_KEY``.
|
|
182
|
+
:param mode: ``"auto"`` (alpha blend, inpaint only what is left), ``"alpha"``
|
|
183
|
+
(lossless only, never inpaints) or ``"inpaint"``.
|
|
184
|
+
:param sparkle: also inpaint the small V3 slot (unverified; can damage text).
|
|
185
|
+
:param retries: extra attempts on 429/5xx/network errors/truncated replies.
|
|
186
|
+
:raises InvalidImageError: the input is not a readable image.
|
|
187
|
+
:raises ApiError: the API rejected the request or kept failing.
|
|
188
|
+
"""
|
|
189
|
+
if mode not in _MODES:
|
|
190
|
+
raise ValueError(f"mode must be one of {_MODES}, got {mode!r}")
|
|
191
|
+
|
|
192
|
+
img, _fmt = _open_image(image)
|
|
193
|
+
width, height = img.size
|
|
194
|
+
w, h = min(width, WINDOW), min(height, WINDOW)
|
|
195
|
+
box = (width - w, height - h, width, height)
|
|
196
|
+
|
|
197
|
+
buf = io.BytesIO()
|
|
198
|
+
img.crop(box).convert("RGB").save(buf, "PNG")
|
|
199
|
+
|
|
200
|
+
patch, info = _post_crop(
|
|
201
|
+
buf.getvalue(),
|
|
202
|
+
(width, height),
|
|
203
|
+
api_url=api_url or os.environ.get(ENV_API_URL) or DEFAULT_API_URL,
|
|
204
|
+
api_key=api_key or os.environ.get(ENV_API_KEY),
|
|
205
|
+
mode=mode,
|
|
206
|
+
sparkle=sparkle,
|
|
207
|
+
timeout=timeout,
|
|
208
|
+
retries=retries,
|
|
209
|
+
session=session,
|
|
210
|
+
)
|
|
211
|
+
|
|
212
|
+
if patch.size != (w, h):
|
|
213
|
+
raise ApiError(f"API returned a {patch.size} corner, expected {(w, h)}")
|
|
214
|
+
|
|
215
|
+
cleaned = img.copy()
|
|
216
|
+
cleaned.paste(patch.convert(cleaned.mode), box[:2])
|
|
217
|
+
|
|
218
|
+
warnings = [s.strip() for s in info.get("x-warnings", "").split("|") if s.strip()]
|
|
219
|
+
confidence = info.get("x-detection-confidence")
|
|
220
|
+
result = Result(
|
|
221
|
+
image=cleaned,
|
|
222
|
+
method=info.get("x-removal-method", "none"),
|
|
223
|
+
detected=info.get("x-watermark-detected", "false").lower() == "true",
|
|
224
|
+
slot=info.get("x-watermark-slot"),
|
|
225
|
+
confidence=float(confidence) if confidence else None,
|
|
226
|
+
clean=info.get("x-clean", "true").lower() == "true",
|
|
227
|
+
warnings=warnings,
|
|
228
|
+
)
|
|
229
|
+
if output is not None:
|
|
230
|
+
result.save(output, quality=quality)
|
|
231
|
+
return result
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
@dataclass
|
|
235
|
+
class BatchItem:
|
|
236
|
+
"""One entry of a :func:`remove_logo_many` run."""
|
|
237
|
+
|
|
238
|
+
source: Path
|
|
239
|
+
result: Optional[Result] = None
|
|
240
|
+
error: Optional[Exception] = None
|
|
241
|
+
|
|
242
|
+
@property
|
|
243
|
+
def ok(self) -> bool:
|
|
244
|
+
return self.error is None
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def remove_logo_many(
|
|
248
|
+
sources: Iterable[Union[str, "os.PathLike[str]"]],
|
|
249
|
+
output_dir: Union[str, "os.PathLike[str]"],
|
|
250
|
+
*,
|
|
251
|
+
workers: int = 3,
|
|
252
|
+
suffix: str = "_clean",
|
|
253
|
+
ext: str = ".png",
|
|
254
|
+
on_done: Optional[Callable[[BatchItem], None]] = None,
|
|
255
|
+
**options,
|
|
256
|
+
) -> List[BatchItem]:
|
|
257
|
+
"""Clean many files in parallel. Never raises for a single bad file.
|
|
258
|
+
|
|
259
|
+
Each input ``photo.jpg`` becomes ``<output_dir>/photo_clean.png``. Failures are
|
|
260
|
+
returned in the list (``item.error``) so one broken image does not stop the rest.
|
|
261
|
+
Keep ``workers`` at 3 or less on the free hosted API.
|
|
262
|
+
"""
|
|
263
|
+
out_dir = Path(output_dir)
|
|
264
|
+
out_dir.mkdir(parents=True, exist_ok=True)
|
|
265
|
+
paths: Sequence[Path] = [Path(p) for p in sources]
|
|
266
|
+
|
|
267
|
+
def run(path: Path) -> BatchItem:
|
|
268
|
+
item = BatchItem(source=path)
|
|
269
|
+
try:
|
|
270
|
+
item.result = remove_logo(path, out_dir / f"{path.stem}{suffix}{ext}", **options)
|
|
271
|
+
except Exception as exc: # noqa: BLE001 - reported per item
|
|
272
|
+
item.error = exc
|
|
273
|
+
if on_done:
|
|
274
|
+
on_done(item)
|
|
275
|
+
return item
|
|
276
|
+
|
|
277
|
+
with ThreadPoolExecutor(max_workers=max(1, workers)) as pool:
|
|
278
|
+
return list(pool.map(run, paths))
|
|
File without changes
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: gemini-logo-remover
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Remove the Gemini watermark from images with one function call, using the Gemini Logo Remover API.
|
|
5
|
+
License: MIT
|
|
6
|
+
Project-URL: Homepage, https://app.gemini-logo-remover.workers.dev
|
|
7
|
+
Project-URL: Documentation, https://app.gemini-logo-remover.workers.dev/developers
|
|
8
|
+
Keywords: gemini,watermark,logo,remover,image,ai
|
|
9
|
+
Classifier: Development Status :: 4 - Beta
|
|
10
|
+
Classifier: Intended Audience :: Developers
|
|
11
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
12
|
+
Classifier: Programming Language :: Python :: 3
|
|
13
|
+
Classifier: Topic :: Multimedia :: Graphics
|
|
14
|
+
Classifier: Typing :: Typed
|
|
15
|
+
Requires-Python: >=3.9
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: requests>=2.28
|
|
19
|
+
Requires-Dist: Pillow>=9.0
|
|
20
|
+
Provides-Extra: dev
|
|
21
|
+
Requires-Dist: pytest>=7; extra == "dev"
|
|
22
|
+
Requires-Dist: build; extra == "dev"
|
|
23
|
+
Requires-Dist: twine; extra == "dev"
|
|
24
|
+
Dynamic: license-file
|
|
25
|
+
|
|
26
|
+
# gemini-logo-remover
|
|
27
|
+
|
|
28
|
+
Remove the Gemini watermark from images with one function call.
|
|
29
|
+
|
|
30
|
+
```bash
|
|
31
|
+
pip install gemini-logo-remover
|
|
32
|
+
```
|
|
33
|
+
|
|
34
|
+
```python
|
|
35
|
+
from gemini_logo_remover import remove_logo
|
|
36
|
+
|
|
37
|
+
remove_logo("gemini-image.png", "clean.png")
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## How it works
|
|
41
|
+
|
|
42
|
+
The watermark only sits in the bottom-right corner. This package cuts out that corner
|
|
43
|
+
(at most 480×480 px), sends **only that small piece** to the Gemini Logo Remover API,
|
|
44
|
+
and pastes the cleaned corner back into your original image on your machine.
|
|
45
|
+
|
|
46
|
+
- Uploads are tiny (a few hundred KB), so it is fast.
|
|
47
|
+
- Every pixel outside the corner is returned exactly as it was — the server never
|
|
48
|
+
re-compresses your picture.
|
|
49
|
+
- The removal itself runs on the hosted API (a Cloudflare Worker), not on your computer.
|
|
50
|
+
Your images are not stored; only the corner crop is sent.
|
|
51
|
+
|
|
52
|
+
The server reverses the watermark's alpha blend (lossless) and falls back to AI inpainting
|
|
53
|
+
only for what that cannot explain.
|
|
54
|
+
|
|
55
|
+
## Usage
|
|
56
|
+
|
|
57
|
+
### One image
|
|
58
|
+
|
|
59
|
+
```python
|
|
60
|
+
from gemini_logo_remover import remove_logo
|
|
61
|
+
|
|
62
|
+
result = remove_logo("in.png", "out.png") # saves out.png
|
|
63
|
+
print(result.method, result.clean, result.warnings)
|
|
64
|
+
|
|
65
|
+
# no file written, work with the Pillow image instead
|
|
66
|
+
result = remove_logo("in.png")
|
|
67
|
+
result.image.show()
|
|
68
|
+
result.save("later.jpg") # .jpg -> JPEG, otherwise PNG
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
`image` can be a path, `bytes`, an open binary file, or a `PIL.Image`.
|
|
72
|
+
|
|
73
|
+
`Result` fields: `image`, `method` (`alpha`, `inpaint`, `alpha+inpaint`, `none`),
|
|
74
|
+
`detected`, `slot`, `confidence`, `clean`, `warnings`, `output_path`.
|
|
75
|
+
|
|
76
|
+
### Many images, in parallel
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
from gemini_logo_remover import remove_logo_many
|
|
80
|
+
|
|
81
|
+
items = remove_logo_many(["a.png", "b.png", "c.jpg"], "cleaned/", workers=3)
|
|
82
|
+
for item in items:
|
|
83
|
+
print(item.source, "ok" if item.ok else f"failed: {item.error}")
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
One bad file never stops the others; failures are returned in the list. Keep `workers`
|
|
87
|
+
at 3 or less on the free hosted API.
|
|
88
|
+
|
|
89
|
+
### Command line
|
|
90
|
+
|
|
91
|
+
```bash
|
|
92
|
+
gemini-logo-remover photo.png # writes photo_clean.png next to it
|
|
93
|
+
gemini-logo-remover *.png -o cleaned/ --workers 3
|
|
94
|
+
gemini-logo-remover photo.png --mode alpha # lossless only
|
|
95
|
+
python -m gemini_logo_remover photo.png # same thing
|
|
96
|
+
```
|
|
97
|
+
|
|
98
|
+
### Options
|
|
99
|
+
|
|
100
|
+
| Option | Default | Meaning |
|
|
101
|
+
| --- | --- | --- |
|
|
102
|
+
| `mode` | `auto` | `auto`: reverse the watermark, inpaint only what is left. `alpha`: lossless only, never inpaints (image unchanged + a warning if it cannot). `inpaint`: skip alpha. |
|
|
103
|
+
| `sparkle` | `False` | Also inpaint the small V3 slot. Unverified; can damage text near the corner. |
|
|
104
|
+
| `api_url` | hosted API | Your own server. Or set `GEMINI_LOGO_REMOVER_API_URL`. |
|
|
105
|
+
| `api_key` | none | If the server requires a key. Or set `GEMINI_LOGO_REMOVER_API_KEY`. |
|
|
106
|
+
| `retries` | `3` | Extra attempts on 429 / 5xx / network errors / cut-off replies. |
|
|
107
|
+
| `timeout` | `180` | Seconds per request. |
|
|
108
|
+
|
|
109
|
+
### Errors
|
|
110
|
+
|
|
111
|
+
- `InvalidImageError` — the input is not a readable image.
|
|
112
|
+
- `ApiError` — the API rejected the request or kept failing. Has `.status` and `.code`.
|
|
113
|
+
- `GeminiLogoRemoverError` — base class of both.
|
|
114
|
+
|
|
115
|
+
## Limits
|
|
116
|
+
|
|
117
|
+
The hosted API runs on a free tier. Expect to process a few images at a time, not
|
|
118
|
+
thousands. Point `api_url` at your own deployment of the server for heavy use.
|
|
119
|
+
|
|
120
|
+
## Development and publishing
|
|
121
|
+
|
|
122
|
+
```bash
|
|
123
|
+
cd python
|
|
124
|
+
pip install -e ".[dev]"
|
|
125
|
+
pytest
|
|
126
|
+
|
|
127
|
+
python -m build # creates dist/*.whl and dist/*.tar.gz
|
|
128
|
+
twine check dist/*
|
|
129
|
+
twine upload --repository testpypi dist/* # try it on TestPyPI first
|
|
130
|
+
twine upload dist/* # publish to PyPI
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
Uploading needs a PyPI account and an API token (pypi.org → Account settings → API tokens);
|
|
134
|
+
use `__token__` as the username and the token as the password. Bump `version` in
|
|
135
|
+
`pyproject.toml` (and `__version__`) for every release — PyPI never lets you re-upload the
|
|
136
|
+
same version.
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
LICENSE
|
|
2
|
+
README.md
|
|
3
|
+
pyproject.toml
|
|
4
|
+
src/gemini_logo_remover/__init__.py
|
|
5
|
+
src/gemini_logo_remover/__main__.py
|
|
6
|
+
src/gemini_logo_remover/cli.py
|
|
7
|
+
src/gemini_logo_remover/client.py
|
|
8
|
+
src/gemini_logo_remover/py.typed
|
|
9
|
+
src/gemini_logo_remover.egg-info/PKG-INFO
|
|
10
|
+
src/gemini_logo_remover.egg-info/SOURCES.txt
|
|
11
|
+
src/gemini_logo_remover.egg-info/dependency_links.txt
|
|
12
|
+
src/gemini_logo_remover.egg-info/entry_points.txt
|
|
13
|
+
src/gemini_logo_remover.egg-info/requires.txt
|
|
14
|
+
src/gemini_logo_remover.egg-info/top_level.txt
|
|
15
|
+
tests/test_client.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
gemini_logo_remover
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
"""Tests against a local fake API over real HTTP (no network, no mocks of requests)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import io
|
|
5
|
+
import json
|
|
6
|
+
import threading
|
|
7
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
from urllib.parse import parse_qs, urlparse
|
|
10
|
+
|
|
11
|
+
import pytest
|
|
12
|
+
from PIL import Image
|
|
13
|
+
|
|
14
|
+
import gemini_logo_remover.client as client_mod
|
|
15
|
+
from gemini_logo_remover import (
|
|
16
|
+
ApiError,
|
|
17
|
+
InvalidImageError,
|
|
18
|
+
remove_logo,
|
|
19
|
+
remove_logo_many,
|
|
20
|
+
)
|
|
21
|
+
from gemini_logo_remover.cli import main as cli_main
|
|
22
|
+
|
|
23
|
+
PNG_START = b"\x89PNG\r\n\x1a\n"
|
|
24
|
+
PNG_END = b"IEND\xaeB`\x82"
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class FakeApi:
|
|
28
|
+
"""Local server. `script` is a list of behaviours, consumed one per request."""
|
|
29
|
+
|
|
30
|
+
def __init__(self) -> None:
|
|
31
|
+
self.requests: list[dict] = []
|
|
32
|
+
self.script: list[str] = []
|
|
33
|
+
self.server = HTTPServer(("127.0.0.1", 0), self._handler())
|
|
34
|
+
self.thread = threading.Thread(target=self.server.serve_forever, daemon=True)
|
|
35
|
+
self.thread.start()
|
|
36
|
+
|
|
37
|
+
@property
|
|
38
|
+
def url(self) -> str:
|
|
39
|
+
return f"http://127.0.0.1:{self.server.server_port}/api/v1/remove"
|
|
40
|
+
|
|
41
|
+
def close(self) -> None:
|
|
42
|
+
self.server.shutdown()
|
|
43
|
+
self.server.server_close()
|
|
44
|
+
|
|
45
|
+
def _handler(self):
|
|
46
|
+
api = self
|
|
47
|
+
|
|
48
|
+
class Handler(BaseHTTPRequestHandler):
|
|
49
|
+
def log_message(self, *args): # silence
|
|
50
|
+
pass
|
|
51
|
+
|
|
52
|
+
def do_POST(self):
|
|
53
|
+
body = self.rfile.read(int(self.headers["Content-Length"]))
|
|
54
|
+
start = body.index(PNG_START)
|
|
55
|
+
end = body.index(PNG_END) + len(PNG_END)
|
|
56
|
+
crop = Image.open(io.BytesIO(body[start:end])).convert("RGB")
|
|
57
|
+
query = parse_qs(urlparse(self.path).query)
|
|
58
|
+
api.requests.append(
|
|
59
|
+
{"query": {k: v[0] for k, v in query.items()}, "key": self.headers.get("X-API-Key"), "size": crop.size}
|
|
60
|
+
)
|
|
61
|
+
action = api.script.pop(0) if api.script else "ok"
|
|
62
|
+
|
|
63
|
+
if action.startswith("status:"):
|
|
64
|
+
status = int(action.split(":")[1])
|
|
65
|
+
payload = json.dumps({"ok": False, "error": {"code": "test_error", "message": f"boom {status}"}}).encode()
|
|
66
|
+
self.send_response(status)
|
|
67
|
+
self.send_header("Content-Type", "application/json")
|
|
68
|
+
self.send_header("Content-Length", str(len(payload)))
|
|
69
|
+
self.end_headers()
|
|
70
|
+
self.wfile.write(payload)
|
|
71
|
+
return
|
|
72
|
+
|
|
73
|
+
# "cleaned" corner: paint a marker so we can see the paste-back
|
|
74
|
+
patch = crop.copy()
|
|
75
|
+
patch.paste((10, 200, 30), (0, 0, 8, 8))
|
|
76
|
+
out = io.BytesIO()
|
|
77
|
+
patch.save(out, "PNG")
|
|
78
|
+
data = out.getvalue()
|
|
79
|
+
if action == "truncated":
|
|
80
|
+
data = data[: len(data) // 2]
|
|
81
|
+
self.send_response(200)
|
|
82
|
+
self.send_header("Content-Type", "image/png")
|
|
83
|
+
self.send_header("X-Removal-Method", "alpha")
|
|
84
|
+
self.send_header("X-Watermark-Detected", "true")
|
|
85
|
+
self.send_header("X-Watermark-Slot", "v2")
|
|
86
|
+
self.send_header("X-Detection-Confidence", "0.95")
|
|
87
|
+
self.send_header("X-Clean", "true")
|
|
88
|
+
self.send_header("X-Warnings", "first | second")
|
|
89
|
+
self.send_header("Content-Length", str(len(out.getvalue()))) # truncated body < declared
|
|
90
|
+
self.end_headers()
|
|
91
|
+
self.wfile.write(data)
|
|
92
|
+
|
|
93
|
+
return Handler
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
@pytest.fixture()
|
|
97
|
+
def api(monkeypatch):
|
|
98
|
+
monkeypatch.setattr(client_mod.time, "sleep", lambda s: None) # no real backoff waits
|
|
99
|
+
server = FakeApi()
|
|
100
|
+
yield server
|
|
101
|
+
server.close()
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def make_image(path: Path, size=(1000, 1200), color=(120, 80, 60)) -> Path:
|
|
105
|
+
Image.new("RGB", size, color).save(path)
|
|
106
|
+
return path
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def test_sends_only_the_corner_and_pastes_it_back(tmp_path, api):
|
|
110
|
+
src = make_image(tmp_path / "in.png")
|
|
111
|
+
result = remove_logo(src, tmp_path / "out.png", api_url=api.url, api_key="k1", mode="alpha")
|
|
112
|
+
|
|
113
|
+
sent = api.requests[0]
|
|
114
|
+
assert sent["size"] == (480, 480) # not the whole 1000x1200 image
|
|
115
|
+
assert sent["query"]["full_size"] == "1000x1200"
|
|
116
|
+
assert sent["query"]["mode"] == "alpha"
|
|
117
|
+
assert sent["query"]["format"] == "png"
|
|
118
|
+
assert sent["query"]["sparkle"] == "false"
|
|
119
|
+
assert sent["key"] == "k1"
|
|
120
|
+
|
|
121
|
+
out = Image.open(tmp_path / "out.png")
|
|
122
|
+
assert out.size == (1000, 1200)
|
|
123
|
+
# marker lands at the top-left of the corner window; everything else is untouched
|
|
124
|
+
assert out.getpixel((1000 - 480 + 2, 1200 - 480 + 2)) == (10, 200, 30)
|
|
125
|
+
assert out.getpixel((10, 10)) == (120, 80, 60)
|
|
126
|
+
assert out.getpixel((999, 1199)) == (120, 80, 60)
|
|
127
|
+
|
|
128
|
+
assert result.method == "alpha" and result.detected and result.slot == "v2"
|
|
129
|
+
assert result.confidence == 0.95 and result.clean
|
|
130
|
+
assert result.warnings == ["first", "second"]
|
|
131
|
+
assert result.output_path == tmp_path / "out.png"
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def test_small_image_sends_the_whole_image(tmp_path, api):
|
|
135
|
+
src = make_image(tmp_path / "s.png", size=(200, 150))
|
|
136
|
+
remove_logo(src, api_url=api.url)
|
|
137
|
+
assert api.requests[0]["size"] == (200, 150)
|
|
138
|
+
assert api.requests[0]["query"]["full_size"] == "200x150"
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
@pytest.mark.parametrize("kind", ["path", "bytes", "file", "pil"])
|
|
142
|
+
def test_accepts_every_input_type(tmp_path, api, kind):
|
|
143
|
+
src = make_image(tmp_path / "in.png")
|
|
144
|
+
value = {
|
|
145
|
+
"path": src,
|
|
146
|
+
"bytes": src.read_bytes(),
|
|
147
|
+
"file": open(src, "rb"),
|
|
148
|
+
"pil": Image.open(src),
|
|
149
|
+
}[kind]
|
|
150
|
+
result = remove_logo(value, api_url=api.url)
|
|
151
|
+
assert result.image.size == (1000, 1200)
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
def test_rgba_input_keeps_alpha(tmp_path, api):
|
|
155
|
+
path = tmp_path / "a.png"
|
|
156
|
+
Image.new("RGBA", (600, 600), (1, 2, 3, 128)).save(path)
|
|
157
|
+
result = remove_logo(path, api_url=api.url)
|
|
158
|
+
assert result.image.mode == "RGBA"
|
|
159
|
+
|
|
160
|
+
|
|
161
|
+
def test_jpeg_output_by_extension(tmp_path, api):
|
|
162
|
+
src = make_image(tmp_path / "in.png")
|
|
163
|
+
result = remove_logo(src, tmp_path / "out.jpg", api_url=api.url)
|
|
164
|
+
assert Image.open(result.output_path).format == "JPEG"
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def test_retries_transient_errors_then_succeeds(tmp_path, api):
|
|
168
|
+
api.script = ["status:503", "truncated", "status:429"]
|
|
169
|
+
src = make_image(tmp_path / "in.png")
|
|
170
|
+
result = remove_logo(src, api_url=api.url, retries=3)
|
|
171
|
+
assert len(api.requests) == 4 # 3 failures + 1 success
|
|
172
|
+
assert result.method == "alpha"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def test_gives_up_after_retries(tmp_path, api):
|
|
176
|
+
api.script = ["status:503"] * 10
|
|
177
|
+
src = make_image(tmp_path / "in.png")
|
|
178
|
+
with pytest.raises(ApiError) as exc:
|
|
179
|
+
remove_logo(src, api_url=api.url, retries=2)
|
|
180
|
+
assert exc.value.status == 503 and exc.value.code == "test_error"
|
|
181
|
+
assert len(api.requests) == 3
|
|
182
|
+
|
|
183
|
+
|
|
184
|
+
def test_does_not_retry_client_errors(tmp_path, api):
|
|
185
|
+
api.script = ["status:401"]
|
|
186
|
+
src = make_image(tmp_path / "in.png")
|
|
187
|
+
with pytest.raises(ApiError) as exc:
|
|
188
|
+
remove_logo(src, api_url=api.url, retries=3)
|
|
189
|
+
assert exc.value.status == 401
|
|
190
|
+
assert len(api.requests) == 1
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
def test_invalid_image_and_bad_mode(tmp_path, api):
|
|
194
|
+
bad = tmp_path / "x.png"
|
|
195
|
+
bad.write_bytes(b"not an image")
|
|
196
|
+
with pytest.raises(InvalidImageError):
|
|
197
|
+
remove_logo(bad, api_url=api.url)
|
|
198
|
+
with pytest.raises(ValueError):
|
|
199
|
+
remove_logo(make_image(tmp_path / "ok.png"), api_url=api.url, mode="nope")
|
|
200
|
+
assert api.requests == []
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def test_env_configuration(tmp_path, api, monkeypatch):
|
|
204
|
+
monkeypatch.setenv("GEMINI_LOGO_REMOVER_API_URL", api.url)
|
|
205
|
+
monkeypatch.setenv("GEMINI_LOGO_REMOVER_API_KEY", "from-env")
|
|
206
|
+
remove_logo(make_image(tmp_path / "in.png"))
|
|
207
|
+
assert api.requests[0]["key"] == "from-env"
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def test_many_runs_in_parallel_and_isolates_failures(tmp_path, api):
|
|
211
|
+
good = [make_image(tmp_path / f"g{i}.png") for i in range(4)]
|
|
212
|
+
bad = tmp_path / "bad.png"
|
|
213
|
+
bad.write_bytes(b"junk")
|
|
214
|
+
items = remove_logo_many(good + [bad], tmp_path / "out", workers=3, api_url=api.url)
|
|
215
|
+
assert [i.ok for i in items] == [True, True, True, True, False]
|
|
216
|
+
assert isinstance(items[-1].error, InvalidImageError)
|
|
217
|
+
assert sorted(p.name for p in (tmp_path / "out").iterdir()) == [f"g{i}_clean.png" for i in range(4)]
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def test_cli(tmp_path, api, capsys):
|
|
221
|
+
a = make_image(tmp_path / "a.png")
|
|
222
|
+
b = make_image(tmp_path / "b.png")
|
|
223
|
+
code = cli_main([str(a), str(b), "-o", str(tmp_path / "res"), "--api-url", api.url, "--mode", "alpha"])
|
|
224
|
+
assert code == 0
|
|
225
|
+
assert (tmp_path / "res" / "a_clean.png").exists() and (tmp_path / "res" / "b_clean.png").exists()
|
|
226
|
+
assert "2 succeeded, 0 failed" in capsys.readouterr().out
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def test_cli_reports_failure_exit_code(tmp_path, api):
|
|
230
|
+
bad = tmp_path / "bad.png"
|
|
231
|
+
bad.write_bytes(b"junk")
|
|
232
|
+
assert cli_main([str(bad), "--api-url", api.url]) == 1
|