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.
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,5 @@
1
+ import sys
2
+
3
+ from .cli import main
4
+
5
+ sys.exit(main())
@@ -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))
@@ -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,2 @@
1
+ [console_scripts]
2
+ gemini-logo-remover = gemini_logo_remover.cli:main
@@ -0,0 +1,7 @@
1
+ requests>=2.28
2
+ Pillow>=9.0
3
+
4
+ [dev]
5
+ pytest>=7
6
+ build
7
+ twine
@@ -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