withoutbg 0.1.0__py3-none-any.whl
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.
- withoutbg/__init__.py +18 -0
- withoutbg/__version__.py +3 -0
- withoutbg/api.py +241 -0
- withoutbg/cli.py +264 -0
- withoutbg/core.py +109 -0
- withoutbg/exceptions.py +31 -0
- withoutbg/models.py +479 -0
- withoutbg/py.typed +0 -0
- withoutbg-0.1.0.dist-info/METADATA +290 -0
- withoutbg-0.1.0.dist-info/RECORD +13 -0
- withoutbg-0.1.0.dist-info/WHEEL +4 -0
- withoutbg-0.1.0.dist-info/entry_points.txt +2 -0
- withoutbg-0.1.0.dist-info/licenses/LICENSE +201 -0
withoutbg/__init__.py
ADDED
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""withoutbg: AI-powered background removal with local and cloud options."""
|
|
2
|
+
|
|
3
|
+
from .__version__ import __version__
|
|
4
|
+
from .api import StudioAPI
|
|
5
|
+
from .core import remove_background, remove_background_batch
|
|
6
|
+
from .exceptions import APIError, ModelNotFoundError, WithoutBGError
|
|
7
|
+
from .models import SnapModel
|
|
8
|
+
|
|
9
|
+
__all__ = [
|
|
10
|
+
"remove_background",
|
|
11
|
+
"remove_background_batch",
|
|
12
|
+
"SnapModel",
|
|
13
|
+
"StudioAPI",
|
|
14
|
+
"WithoutBGError",
|
|
15
|
+
"ModelNotFoundError",
|
|
16
|
+
"APIError",
|
|
17
|
+
"__version__",
|
|
18
|
+
]
|
withoutbg/__version__.py
ADDED
withoutbg/api.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Studio API client for cloud-based background removal."""
|
|
2
|
+
|
|
3
|
+
import base64
|
|
4
|
+
import io
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any, Optional, Union
|
|
7
|
+
|
|
8
|
+
import requests
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from .exceptions import APIError
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class StudioAPI:
|
|
15
|
+
"""Client for withoutbg Studio API."""
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self, api_key: Optional[str] = None, base_url: str = "https://api.withoutbg.com"
|
|
19
|
+
):
|
|
20
|
+
"""Initialize Studio API client.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
api_key: API key for authentication
|
|
24
|
+
base_url: Base URL for API endpoints
|
|
25
|
+
"""
|
|
26
|
+
self.api_key = api_key
|
|
27
|
+
self.base_url = base_url.rstrip("/")
|
|
28
|
+
self.session = requests.Session()
|
|
29
|
+
|
|
30
|
+
if api_key:
|
|
31
|
+
self.session.headers.update({"X-API-Key": api_key})
|
|
32
|
+
|
|
33
|
+
def _encode_image(self, image: Union[str, Path, Image.Image, bytes]) -> str:
|
|
34
|
+
"""Encode image to base64 for API transmission."""
|
|
35
|
+
if isinstance(image, (str, Path)):
|
|
36
|
+
with open(image, "rb") as f:
|
|
37
|
+
image_bytes = f.read()
|
|
38
|
+
elif isinstance(image, Image.Image):
|
|
39
|
+
buffer = io.BytesIO()
|
|
40
|
+
image.save(buffer, format="PNG")
|
|
41
|
+
image_bytes = buffer.getvalue()
|
|
42
|
+
elif isinstance(image, bytes):
|
|
43
|
+
image_bytes = image
|
|
44
|
+
else:
|
|
45
|
+
raise APIError(f"Unsupported image type: {type(image)}")
|
|
46
|
+
|
|
47
|
+
return base64.b64encode(image_bytes).decode("utf-8")
|
|
48
|
+
|
|
49
|
+
def _decode_image(self, base64_string: str) -> Image.Image:
|
|
50
|
+
"""Decode base64 string to PIL Image."""
|
|
51
|
+
image_bytes = base64.b64decode(base64_string)
|
|
52
|
+
with Image.open(io.BytesIO(image_bytes)) as img:
|
|
53
|
+
return img.copy()
|
|
54
|
+
|
|
55
|
+
def _resize_for_api(
|
|
56
|
+
self, image: Image.Image, max_size: int = 1024
|
|
57
|
+
) -> tuple[Image.Image, tuple[int, int]]:
|
|
58
|
+
"""Resize image for API transmission while preserving aspect ratio.
|
|
59
|
+
|
|
60
|
+
Args:
|
|
61
|
+
image: Input PIL Image
|
|
62
|
+
max_size: Maximum dimension size (default 1024)
|
|
63
|
+
|
|
64
|
+
Returns:
|
|
65
|
+
Tuple of (resized_image, original_size)
|
|
66
|
+
"""
|
|
67
|
+
original_size = image.size
|
|
68
|
+
width, height = original_size
|
|
69
|
+
|
|
70
|
+
# If image is already smaller than max_size, return as-is
|
|
71
|
+
if max(width, height) <= max_size:
|
|
72
|
+
return image, original_size
|
|
73
|
+
|
|
74
|
+
# Calculate new dimensions preserving aspect ratio
|
|
75
|
+
if width > height:
|
|
76
|
+
new_width = max_size
|
|
77
|
+
new_height = int((height * max_size) / width)
|
|
78
|
+
else:
|
|
79
|
+
new_height = max_size
|
|
80
|
+
new_width = int((width * max_size) / height)
|
|
81
|
+
|
|
82
|
+
resized_image = image.resize((new_width, new_height), Image.Resampling.LANCZOS)
|
|
83
|
+
return resized_image, original_size
|
|
84
|
+
|
|
85
|
+
def _apply_alpha_channel(
|
|
86
|
+
self, original_image: Image.Image, alpha_image: Image.Image
|
|
87
|
+
) -> Image.Image:
|
|
88
|
+
"""Apply alpha channel to original image to remove background.
|
|
89
|
+
|
|
90
|
+
Args:
|
|
91
|
+
original_image: Original RGB/RGBA image
|
|
92
|
+
alpha_image: Grayscale alpha channel image
|
|
93
|
+
|
|
94
|
+
Returns:
|
|
95
|
+
RGBA image with background removed
|
|
96
|
+
"""
|
|
97
|
+
# Ensure original image is in RGB mode
|
|
98
|
+
if original_image.mode != "RGB":
|
|
99
|
+
original_image = original_image.convert("RGB")
|
|
100
|
+
|
|
101
|
+
# Ensure alpha image is grayscale
|
|
102
|
+
if alpha_image.mode != "L":
|
|
103
|
+
alpha_image = alpha_image.convert("L")
|
|
104
|
+
|
|
105
|
+
# Resize alpha to match original image if needed
|
|
106
|
+
if alpha_image.size != original_image.size:
|
|
107
|
+
alpha_image = alpha_image.resize(
|
|
108
|
+
original_image.size, Image.Resampling.LANCZOS
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
# Create RGBA image by adding alpha channel
|
|
112
|
+
rgba_image = original_image.copy()
|
|
113
|
+
rgba_image.putalpha(alpha_image)
|
|
114
|
+
|
|
115
|
+
return rgba_image
|
|
116
|
+
|
|
117
|
+
def remove_background(
|
|
118
|
+
self, input_image: Union[str, Path, Image.Image, bytes], **kwargs: Any
|
|
119
|
+
) -> Image.Image:
|
|
120
|
+
"""Remove background using Studio API.
|
|
121
|
+
|
|
122
|
+
Args:
|
|
123
|
+
input_image: Input image
|
|
124
|
+
**kwargs: Additional API parameters
|
|
125
|
+
|
|
126
|
+
Returns:
|
|
127
|
+
PIL Image with background removed
|
|
128
|
+
|
|
129
|
+
Raises:
|
|
130
|
+
APIError: If API request fails
|
|
131
|
+
"""
|
|
132
|
+
if not self.api_key:
|
|
133
|
+
raise APIError("API key required for Studio service")
|
|
134
|
+
|
|
135
|
+
try:
|
|
136
|
+
# Store original image for local alpha application
|
|
137
|
+
if isinstance(input_image, (str, Path)):
|
|
138
|
+
with Image.open(input_image) as img:
|
|
139
|
+
original_image = img.copy()
|
|
140
|
+
elif isinstance(input_image, Image.Image):
|
|
141
|
+
original_image = input_image.copy()
|
|
142
|
+
elif isinstance(input_image, bytes):
|
|
143
|
+
with Image.open(io.BytesIO(input_image)) as img:
|
|
144
|
+
original_image = img.copy()
|
|
145
|
+
else:
|
|
146
|
+
raise APIError(f"Unsupported image type: {type(input_image)}")
|
|
147
|
+
|
|
148
|
+
# Resize image for API transmission to optimize latency
|
|
149
|
+
api_image, original_size = self._resize_for_api(original_image)
|
|
150
|
+
|
|
151
|
+
# Encode resized image
|
|
152
|
+
encoded_image = self._encode_image(api_image)
|
|
153
|
+
|
|
154
|
+
# Prepare request for base64 endpoint
|
|
155
|
+
payload = {"image_base64": encoded_image}
|
|
156
|
+
|
|
157
|
+
# Make API request to base64 endpoint
|
|
158
|
+
response = self.session.post(
|
|
159
|
+
f"{self.base_url}/v1.0/alpha-channel-base64", json=payload, timeout=30
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
if response.status_code == 401:
|
|
163
|
+
raise APIError("Invalid API key")
|
|
164
|
+
elif response.status_code == 429:
|
|
165
|
+
raise APIError("Rate limit exceeded (7 requests per minute)")
|
|
166
|
+
elif response.status_code == 402:
|
|
167
|
+
raise APIError(
|
|
168
|
+
"Insufficient credits. Get more credits at https://withoutbg.com/login"
|
|
169
|
+
)
|
|
170
|
+
elif response.status_code == 403:
|
|
171
|
+
raise APIError(
|
|
172
|
+
"Credits expired. Top up your account to reactivate "
|
|
173
|
+
"frozen credits at https://withoutbg.com/login"
|
|
174
|
+
)
|
|
175
|
+
elif not response.ok:
|
|
176
|
+
try:
|
|
177
|
+
error_msg = response.json().get("error", "Unknown API error")
|
|
178
|
+
except Exception:
|
|
179
|
+
error_msg = f"HTTP {response.status_code}: {response.text}"
|
|
180
|
+
raise APIError(f"API request failed: {error_msg}")
|
|
181
|
+
|
|
182
|
+
# Decode response
|
|
183
|
+
result_data = response.json()
|
|
184
|
+
|
|
185
|
+
if "alpha_base64" not in result_data:
|
|
186
|
+
raise APIError(
|
|
187
|
+
f"Invalid API response format\n\n"
|
|
188
|
+
f"More info: sample response: {result_data}"
|
|
189
|
+
)
|
|
190
|
+
|
|
191
|
+
# Decode alpha channel
|
|
192
|
+
alpha_image = self._decode_image(result_data["alpha_base64"])
|
|
193
|
+
|
|
194
|
+
# Resize alpha channel back to original dimensions if needed
|
|
195
|
+
if alpha_image.size != original_size:
|
|
196
|
+
alpha_image = alpha_image.resize(
|
|
197
|
+
original_size, Image.Resampling.LANCZOS
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Apply alpha channel to original image locally
|
|
201
|
+
return self._apply_alpha_channel(original_image, alpha_image)
|
|
202
|
+
|
|
203
|
+
except requests.RequestException as e:
|
|
204
|
+
raise APIError(f"Network error: {str(e)}") from e
|
|
205
|
+
except Exception as e:
|
|
206
|
+
if isinstance(e, APIError):
|
|
207
|
+
raise
|
|
208
|
+
raise APIError(f"Unexpected error: {str(e)}") from e
|
|
209
|
+
|
|
210
|
+
def get_usage(self) -> dict[str, Any]:
|
|
211
|
+
"""Get current API usage statistics.
|
|
212
|
+
|
|
213
|
+
Returns:
|
|
214
|
+
Dictionary with usage information
|
|
215
|
+
"""
|
|
216
|
+
if not self.api_key:
|
|
217
|
+
raise APIError("API key required")
|
|
218
|
+
|
|
219
|
+
try:
|
|
220
|
+
response = self.session.get(f"{self.base_url}/available-credit")
|
|
221
|
+
response.raise_for_status()
|
|
222
|
+
result: dict[str, Any] = response.json()
|
|
223
|
+
return result
|
|
224
|
+
|
|
225
|
+
except requests.RequestException as e:
|
|
226
|
+
raise APIError(f"Failed to get usage: {str(e)}") from e
|
|
227
|
+
|
|
228
|
+
def get_models(self) -> dict[str, Any]:
|
|
229
|
+
"""Get available models and their capabilities.
|
|
230
|
+
|
|
231
|
+
Returns:
|
|
232
|
+
Dictionary with model information
|
|
233
|
+
"""
|
|
234
|
+
try:
|
|
235
|
+
response = self.session.get(f"{self.base_url}/v1/models")
|
|
236
|
+
response.raise_for_status()
|
|
237
|
+
result: dict[str, Any] = response.json()
|
|
238
|
+
return result
|
|
239
|
+
|
|
240
|
+
except requests.RequestException as e:
|
|
241
|
+
raise APIError(f"Failed to get models: {str(e)}") from e
|
withoutbg/cli.py
ADDED
|
@@ -0,0 +1,264 @@
|
|
|
1
|
+
"""Command-line interface for withoutbg."""
|
|
2
|
+
|
|
3
|
+
import sys
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
from typing import Any, Optional
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from click._termui_impl import ProgressBar
|
|
9
|
+
from PIL import Image
|
|
10
|
+
|
|
11
|
+
from . import __version__, remove_background
|
|
12
|
+
from .exceptions import WithoutBGError
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@click.command()
|
|
16
|
+
@click.argument("input_path", type=click.Path(exists=True, path_type=Path))
|
|
17
|
+
@click.option(
|
|
18
|
+
"--output",
|
|
19
|
+
"-o",
|
|
20
|
+
type=click.Path(path_type=Path),
|
|
21
|
+
help="Output file path (default: adds -withoutbg suffix)",
|
|
22
|
+
)
|
|
23
|
+
@click.option(
|
|
24
|
+
"--api-key",
|
|
25
|
+
envvar="WITHOUTBG_API_KEY",
|
|
26
|
+
help="API key for Studio service (or set WITHOUTBG_API_KEY env var)",
|
|
27
|
+
)
|
|
28
|
+
@click.option(
|
|
29
|
+
"--use-api", is_flag=True, help="Use Studio API instead of local Snap model"
|
|
30
|
+
)
|
|
31
|
+
@click.option(
|
|
32
|
+
"--model",
|
|
33
|
+
default="snap",
|
|
34
|
+
type=click.Choice(["snap", "studio"]),
|
|
35
|
+
help="Model to use (snap=local, studio=API)",
|
|
36
|
+
)
|
|
37
|
+
@click.option(
|
|
38
|
+
"--batch",
|
|
39
|
+
is_flag=True,
|
|
40
|
+
help="Process all images in directory (if input is directory)",
|
|
41
|
+
)
|
|
42
|
+
@click.option(
|
|
43
|
+
"--output-dir",
|
|
44
|
+
type=click.Path(path_type=Path),
|
|
45
|
+
help="Output directory for batch processing",
|
|
46
|
+
)
|
|
47
|
+
@click.option(
|
|
48
|
+
"--format",
|
|
49
|
+
type=click.Choice(["png", "jpg", "webp"]),
|
|
50
|
+
default="png",
|
|
51
|
+
help="Output image format",
|
|
52
|
+
)
|
|
53
|
+
@click.option("--quality", type=int, default=95, help="Output quality for JPEG (1-100)")
|
|
54
|
+
@click.option("--verbose", "-v", is_flag=True, help="Enable verbose output")
|
|
55
|
+
@click.version_option(version=__version__)
|
|
56
|
+
def main(
|
|
57
|
+
input_path: Path,
|
|
58
|
+
output: Optional[Path],
|
|
59
|
+
api_key: Optional[str],
|
|
60
|
+
use_api: bool,
|
|
61
|
+
model: str,
|
|
62
|
+
batch: bool,
|
|
63
|
+
output_dir: Optional[Path],
|
|
64
|
+
format: str,
|
|
65
|
+
quality: int,
|
|
66
|
+
verbose: bool,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Remove background from images using AI.
|
|
69
|
+
|
|
70
|
+
Examples:
|
|
71
|
+
|
|
72
|
+
# Process single image with local Snap model
|
|
73
|
+
withoutbg image.jpg
|
|
74
|
+
|
|
75
|
+
# Use Studio API for best quality processing
|
|
76
|
+
withoutbg image.jpg --use-api --api-key sk_...
|
|
77
|
+
|
|
78
|
+
# Process all images in directory
|
|
79
|
+
withoutbg photos/ --batch --output-dir results/
|
|
80
|
+
|
|
81
|
+
# Specify output format and quality
|
|
82
|
+
withoutbg image.jpg --format jpg --quality 90
|
|
83
|
+
"""
|
|
84
|
+
|
|
85
|
+
try:
|
|
86
|
+
# Determine if using API
|
|
87
|
+
using_api = use_api or api_key or model == "studio"
|
|
88
|
+
|
|
89
|
+
if using_api and not api_key:
|
|
90
|
+
click.echo("Error: API key required when using Studio service", err=True)
|
|
91
|
+
click.echo(
|
|
92
|
+
"Set WITHOUTBG_API_KEY environment variable or use --api-key", err=True
|
|
93
|
+
)
|
|
94
|
+
sys.exit(1)
|
|
95
|
+
|
|
96
|
+
# Configure model
|
|
97
|
+
actual_model = "studio" if using_api else "snap"
|
|
98
|
+
|
|
99
|
+
if verbose:
|
|
100
|
+
mode = "Studio API" if using_api else "Local Snap model"
|
|
101
|
+
click.echo(f"Using {mode} for processing...")
|
|
102
|
+
|
|
103
|
+
# Process images
|
|
104
|
+
if batch or input_path.is_dir():
|
|
105
|
+
_process_batch(
|
|
106
|
+
input_path, output_dir, api_key, actual_model, format, quality, verbose
|
|
107
|
+
)
|
|
108
|
+
else:
|
|
109
|
+
_process_single(
|
|
110
|
+
input_path, output, api_key, actual_model, format, quality, verbose
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
if verbose:
|
|
114
|
+
api_msg = ""
|
|
115
|
+
if not using_api:
|
|
116
|
+
api_msg = " (Want best quality? Try withoutbg.com)"
|
|
117
|
+
click.echo(f"✅ Processing complete!{api_msg}")
|
|
118
|
+
|
|
119
|
+
except WithoutBGError as e:
|
|
120
|
+
click.echo(f"Error: {e}", err=True)
|
|
121
|
+
sys.exit(1)
|
|
122
|
+
except KeyboardInterrupt:
|
|
123
|
+
click.echo("\n❌ Processing cancelled", err=True)
|
|
124
|
+
sys.exit(1)
|
|
125
|
+
except Exception as e:
|
|
126
|
+
click.echo(f"Unexpected error: {e}", err=True)
|
|
127
|
+
sys.exit(1)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def _process_single(
|
|
131
|
+
input_path: Path,
|
|
132
|
+
output_path: Optional[Path],
|
|
133
|
+
api_key: Optional[str],
|
|
134
|
+
model: str,
|
|
135
|
+
format: str,
|
|
136
|
+
quality: int,
|
|
137
|
+
verbose: bool,
|
|
138
|
+
) -> None:
|
|
139
|
+
"""Process a single image."""
|
|
140
|
+
|
|
141
|
+
if not output_path:
|
|
142
|
+
# Generate output filename
|
|
143
|
+
stem = input_path.stem
|
|
144
|
+
output_path = input_path.parent / f"{stem}-withoutbg.{format}"
|
|
145
|
+
|
|
146
|
+
if verbose:
|
|
147
|
+
click.echo(f"Processing: {input_path}")
|
|
148
|
+
click.echo(f"Output: {output_path}")
|
|
149
|
+
|
|
150
|
+
# Remove background
|
|
151
|
+
bar: ProgressBar
|
|
152
|
+
with click.progressbar(length=1, label="Removing background") as bar:
|
|
153
|
+
result = remove_background(input_path, api_key=api_key, model_name=model)
|
|
154
|
+
bar.update(1)
|
|
155
|
+
|
|
156
|
+
# Save result
|
|
157
|
+
save_kwargs: dict[str, Any] = {}
|
|
158
|
+
if format.lower() == "jpg":
|
|
159
|
+
# Convert RGBA to RGB for JPEG
|
|
160
|
+
if result.mode == "RGBA":
|
|
161
|
+
background = Image.new("RGB", result.size, (255, 255, 255))
|
|
162
|
+
background.paste(result, mask=result.split()[-1]) # Use alpha as mask
|
|
163
|
+
result = background
|
|
164
|
+
save_kwargs["quality"] = quality
|
|
165
|
+
save_kwargs["optimize"] = True
|
|
166
|
+
elif format.lower() == "webp":
|
|
167
|
+
save_kwargs["quality"] = quality
|
|
168
|
+
save_kwargs["method"] = 6 # Best compression
|
|
169
|
+
|
|
170
|
+
# Map format names to PIL format strings
|
|
171
|
+
pil_format = {"jpg": "JPEG", "jpeg": "JPEG", "png": "PNG", "webp": "WEBP"}
|
|
172
|
+
result.save(
|
|
173
|
+
output_path,
|
|
174
|
+
format=pil_format.get(format.lower(), format.upper()),
|
|
175
|
+
**save_kwargs,
|
|
176
|
+
)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def _process_batch(
|
|
180
|
+
input_dir: Path,
|
|
181
|
+
output_dir: Optional[Path],
|
|
182
|
+
api_key: Optional[str],
|
|
183
|
+
model: str,
|
|
184
|
+
format: str,
|
|
185
|
+
quality: int,
|
|
186
|
+
verbose: bool,
|
|
187
|
+
) -> None:
|
|
188
|
+
"""Process multiple images in a directory."""
|
|
189
|
+
|
|
190
|
+
# Find image files
|
|
191
|
+
image_extensions = {".jpg", ".jpeg", ".png", ".bmp", ".tiff", ".webp"}
|
|
192
|
+
|
|
193
|
+
if input_dir.is_file():
|
|
194
|
+
# Single file specified with --batch flag
|
|
195
|
+
image_files = [input_dir]
|
|
196
|
+
input_dir = input_dir.parent
|
|
197
|
+
else:
|
|
198
|
+
# Directory specified
|
|
199
|
+
image_files = [
|
|
200
|
+
f
|
|
201
|
+
for f in input_dir.iterdir()
|
|
202
|
+
if f.is_file() and f.suffix.lower() in image_extensions
|
|
203
|
+
]
|
|
204
|
+
|
|
205
|
+
if not image_files:
|
|
206
|
+
click.echo("No image files found in directory", err=True)
|
|
207
|
+
sys.exit(1)
|
|
208
|
+
|
|
209
|
+
# Set up output directory
|
|
210
|
+
if not output_dir:
|
|
211
|
+
output_dir = input_dir / "withoutbg-results"
|
|
212
|
+
|
|
213
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
214
|
+
|
|
215
|
+
if verbose:
|
|
216
|
+
click.echo(f"Found {len(image_files)} images")
|
|
217
|
+
click.echo(f"Output directory: {output_dir}")
|
|
218
|
+
|
|
219
|
+
# Process images
|
|
220
|
+
with click.progressbar(image_files, label="Processing images") as bar:
|
|
221
|
+
for image_file in bar:
|
|
222
|
+
try:
|
|
223
|
+
# Generate output path
|
|
224
|
+
output_path = output_dir / f"{image_file.stem}-withoutbg.{format}"
|
|
225
|
+
|
|
226
|
+
# Remove background
|
|
227
|
+
result = remove_background(
|
|
228
|
+
image_file, api_key=api_key, model_name=model
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
# Save result
|
|
232
|
+
save_kwargs: dict[str, Any] = {}
|
|
233
|
+
if format.lower() == "jpg":
|
|
234
|
+
if result.mode == "RGBA":
|
|
235
|
+
background = Image.new("RGB", result.size, (255, 255, 255))
|
|
236
|
+
background.paste(result, mask=result.split()[-1])
|
|
237
|
+
result = background
|
|
238
|
+
save_kwargs["quality"] = quality
|
|
239
|
+
save_kwargs["optimize"] = True
|
|
240
|
+
elif format.lower() == "webp":
|
|
241
|
+
save_kwargs["quality"] = quality
|
|
242
|
+
save_kwargs["method"] = 6
|
|
243
|
+
|
|
244
|
+
# Map format names to PIL format strings
|
|
245
|
+
pil_format = {
|
|
246
|
+
"jpg": "JPEG",
|
|
247
|
+
"jpeg": "JPEG",
|
|
248
|
+
"png": "PNG",
|
|
249
|
+
"webp": "WEBP",
|
|
250
|
+
}
|
|
251
|
+
result.save(
|
|
252
|
+
output_path,
|
|
253
|
+
format=pil_format.get(format.lower(), format.upper()),
|
|
254
|
+
**save_kwargs,
|
|
255
|
+
)
|
|
256
|
+
|
|
257
|
+
except Exception as e:
|
|
258
|
+
if verbose:
|
|
259
|
+
click.echo(f"\n❌ Failed to process {image_file}: {e}", err=True)
|
|
260
|
+
continue
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
if __name__ == "__main__":
|
|
264
|
+
main()
|
withoutbg/core.py
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""Core background removal functionality."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
from typing import Any, Optional, Union
|
|
5
|
+
|
|
6
|
+
from PIL import Image
|
|
7
|
+
|
|
8
|
+
from .api import StudioAPI
|
|
9
|
+
from .exceptions import WithoutBGError
|
|
10
|
+
from .models import SnapModel
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def remove_background(
|
|
14
|
+
input_image: Union[str, Path, Image.Image, bytes],
|
|
15
|
+
output_path: Optional[Union[str, Path]] = None,
|
|
16
|
+
api_key: Optional[str] = None,
|
|
17
|
+
model_name: str = "snap",
|
|
18
|
+
**kwargs: Any,
|
|
19
|
+
) -> Image.Image:
|
|
20
|
+
"""Remove background from an image.
|
|
21
|
+
|
|
22
|
+
Args:
|
|
23
|
+
input_image: Input image as file path, PIL Image, or bytes
|
|
24
|
+
output_path: Optional path to save the result
|
|
25
|
+
api_key: API key for Studio service (uses local Snap model if None)
|
|
26
|
+
model_name: Model to use ("snap" for local, "studio" for API)
|
|
27
|
+
**kwargs: Additional arguments passed to the model/API
|
|
28
|
+
|
|
29
|
+
Returns:
|
|
30
|
+
PIL Image with background removed
|
|
31
|
+
|
|
32
|
+
Examples:
|
|
33
|
+
>>> # Local processing with Snap model
|
|
34
|
+
>>> result = remove_background("input.jpg")
|
|
35
|
+
|
|
36
|
+
>>> # Cloud processing with Studio API
|
|
37
|
+
>>> result = remove_background("input.jpg", api_key="sk_...")
|
|
38
|
+
|
|
39
|
+
>>> # Save result directly
|
|
40
|
+
>>> remove_background("input.jpg", output_path="output.png")
|
|
41
|
+
"""
|
|
42
|
+
try:
|
|
43
|
+
if api_key or model_name == "studio":
|
|
44
|
+
# Use Studio API
|
|
45
|
+
api = StudioAPI(api_key)
|
|
46
|
+
result = api.remove_background(input_image, **kwargs)
|
|
47
|
+
else:
|
|
48
|
+
# Use local Snap model
|
|
49
|
+
model = SnapModel()
|
|
50
|
+
result = model.remove_background(input_image, **kwargs)
|
|
51
|
+
|
|
52
|
+
if output_path:
|
|
53
|
+
result.save(output_path)
|
|
54
|
+
|
|
55
|
+
return result
|
|
56
|
+
|
|
57
|
+
except Exception as e:
|
|
58
|
+
raise WithoutBGError(f"Background removal failed: {str(e)}") from e
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def remove_background_batch(
|
|
62
|
+
input_images: list[Union[str, Path, Image.Image, bytes]],
|
|
63
|
+
output_dir: Optional[Union[str, Path]] = None,
|
|
64
|
+
api_key: Optional[str] = None,
|
|
65
|
+
model_name: str = "snap",
|
|
66
|
+
**kwargs: Any,
|
|
67
|
+
) -> list[Image.Image]:
|
|
68
|
+
"""Remove background from multiple images.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
input_images: List of input images
|
|
72
|
+
output_dir: Directory to save results (optional)
|
|
73
|
+
api_key: API key for Studio service
|
|
74
|
+
model_name: Model to use
|
|
75
|
+
**kwargs: Additional arguments
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
List of PIL Images with backgrounds removed
|
|
79
|
+
"""
|
|
80
|
+
results = []
|
|
81
|
+
|
|
82
|
+
for i, input_image in enumerate(input_images):
|
|
83
|
+
output_path = None
|
|
84
|
+
if output_dir:
|
|
85
|
+
output_dir = Path(output_dir)
|
|
86
|
+
output_dir.mkdir(parents=True, exist_ok=True)
|
|
87
|
+
|
|
88
|
+
# Try to get original filename
|
|
89
|
+
if isinstance(input_image, (str, Path)):
|
|
90
|
+
input_path = Path(input_image)
|
|
91
|
+
stem = input_path.stem
|
|
92
|
+
suffix = input_path.suffix or ".png"
|
|
93
|
+
output_filename = f"{stem}-withoutbg{suffix}"
|
|
94
|
+
else:
|
|
95
|
+
# For PIL Images or bytes, use numbered fallback
|
|
96
|
+
output_filename = f"output_{i:04d}-withoutbg.png"
|
|
97
|
+
|
|
98
|
+
output_path = output_dir / output_filename
|
|
99
|
+
|
|
100
|
+
result = remove_background(
|
|
101
|
+
input_image,
|
|
102
|
+
output_path=output_path,
|
|
103
|
+
api_key=api_key,
|
|
104
|
+
model_name=model_name,
|
|
105
|
+
**kwargs,
|
|
106
|
+
)
|
|
107
|
+
results.append(result)
|
|
108
|
+
|
|
109
|
+
return results
|
withoutbg/exceptions.py
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Custom exceptions for withoutbg package."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class WithoutBGError(Exception):
|
|
5
|
+
"""Base exception for withoutbg package."""
|
|
6
|
+
|
|
7
|
+
pass
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ModelNotFoundError(WithoutBGError):
|
|
11
|
+
"""Raised when a model file cannot be found or loaded."""
|
|
12
|
+
|
|
13
|
+
pass
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class APIError(WithoutBGError):
|
|
17
|
+
"""Raised when API requests fail."""
|
|
18
|
+
|
|
19
|
+
pass
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class InvalidImageError(WithoutBGError):
|
|
23
|
+
"""Raised when image input is invalid or corrupted."""
|
|
24
|
+
|
|
25
|
+
pass
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class ConfigurationError(WithoutBGError):
|
|
29
|
+
"""Raised when configuration is invalid."""
|
|
30
|
+
|
|
31
|
+
pass
|