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 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
+ ]
@@ -0,0 +1,3 @@
1
+ """Version information for withoutbg."""
2
+
3
+ __version__ = "0.1.0"
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
@@ -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