bulk-bg-remove 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.
@@ -0,0 +1,3 @@
1
+ """Remove image backgrounds in bulk, locally, without touching a single subject pixel."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1 @@
1
+ """The four phases of a bulk run: scan -> plan -> run -> verify."""
@@ -0,0 +1,28 @@
1
+ """Progress events — one stream feeds the terminal, `--json` output and the UI.
2
+
3
+ Every event is a flat JSON object with a `type`. This is a public contract:
4
+ agents parse it, so only add fields, never rename or remove them.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import time
10
+ from collections.abc import Callable
11
+ from dataclasses import dataclass, field
12
+ from typing import TypeAlias
13
+
14
+ JSONValue: TypeAlias = "str | int | float | bool | None | list[JSONValue] | dict[str, JSONValue]"
15
+ JSONObject: TypeAlias = dict[str, JSONValue]
16
+
17
+
18
+ @dataclass(frozen=True, slots=True)
19
+ class Event:
20
+ type: str
21
+ data: JSONObject = field(default_factory=dict)
22
+ at: float = field(default_factory=time.time)
23
+
24
+ def to_dict(self) -> JSONObject:
25
+ return {"type": self.type, "at": round(self.at, 3), **self.data}
26
+
27
+
28
+ EventSink = Callable[[Event], None]
@@ -0,0 +1,137 @@
1
+ """Pick a model for the kind of images and the RAM that is free (`--model auto`).
2
+
3
+ The scores are the blind-judged means (0-10) from the bench behind
4
+ `skills/bulk-bg-remove/references/models.md`: 11 models on 80 images, each
5
+ image tagged by content. A profile groups those tags into the kinds of work an
6
+ agent can tell apart by looking at a few photos.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from dataclasses import dataclass
12
+ from enum import StrEnum
13
+
14
+ from ..core.matting import EdgeMode
15
+ from .events import JSONObject
16
+ from .plan import minimum_free_ram_gb
17
+
18
+ AUTO_MODEL = "auto"
19
+
20
+
21
+ class Profile(StrEnum):
22
+ UNIFORM = "uniform" # products and objects with clean outlines (n=39)
23
+ COMPLEX = "complex" # hair, fur, plants, wires, thin parts (n=32)
24
+ PEOPLE = "people" # portraits and groups (n=8)
25
+ SCENE = "scene" # cluttered scenes with no clean background (n=6)
26
+ DRAWING = "drawing" # cartoons, anime, flat illustration (n=1)
27
+
28
+
29
+ PROFILE_SCORES: dict[Profile, dict[str, float]] = {
30
+ Profile.UNIFORM: {
31
+ "birefnet-general-lite": 8.6,
32
+ "birefnet-general": 8.6,
33
+ "birefnet-hrsod": 8.5,
34
+ "birefnet-massive": 8.5,
35
+ "birefnet-dis": 8.2,
36
+ "u2net": 8.1,
37
+ "silueta": 8.1,
38
+ "isnet-general-use": 7.7,
39
+ },
40
+ Profile.COMPLEX: {
41
+ "birefnet-general": 7.6,
42
+ "birefnet-general-lite": 7.4,
43
+ "birefnet-hrsod": 7.3,
44
+ "birefnet-massive": 7.0,
45
+ "birefnet-dis": 6.9,
46
+ "isnet-general-use": 6.0,
47
+ "silueta": 5.2,
48
+ "u2net": 5.2,
49
+ },
50
+ Profile.PEOPLE: {
51
+ "birefnet-general": 7.8,
52
+ "birefnet-dis": 7.4,
53
+ "birefnet-massive": 7.2,
54
+ "birefnet-general-lite": 7.1,
55
+ "birefnet-portrait": 7.1,
56
+ "u2net": 6.0,
57
+ "silueta": 5.8,
58
+ "isnet-general-use": 5.2,
59
+ },
60
+ Profile.SCENE: {
61
+ "birefnet-general": 6.3,
62
+ "birefnet-hrsod": 5.7,
63
+ "birefnet-massive": 5.3,
64
+ "birefnet-dis": 5.3,
65
+ "birefnet-general-lite": 5.2,
66
+ "u2net": 4.0,
67
+ "silueta": 4.0,
68
+ "isnet-general-use": 3.7,
69
+ },
70
+ Profile.DRAWING: {
71
+ "isnet-anime": 9.0,
72
+ "birefnet-general-lite": 8.0,
73
+ "birefnet-massive": 7.0,
74
+ },
75
+ }
76
+
77
+ # Gaps up to this many points are ties on the bench, so auto may take the
78
+ # lighter model on its own; a larger loss needs the user's OK (--allow-fallback).
79
+ ALLOWED_GAP = 0.5
80
+
81
+
82
+ @dataclass(frozen=True, slots=True)
83
+ class ModelChoice:
84
+ profile: Profile
85
+ model: str
86
+ score: float
87
+ best_model: str
88
+ best_score: float
89
+ best_needs_gb: float
90
+ needs_gb: float # free RAM the chosen model needs
91
+ free_ram_gb: float
92
+
93
+ @property
94
+ def gap(self) -> float:
95
+ return round(self.best_score - self.score, 2)
96
+
97
+ @property
98
+ def fits(self) -> bool:
99
+ return self.free_ram_gb >= self.needs_gb
100
+
101
+ @property
102
+ def is_fallback(self) -> bool:
103
+ """True when the chosen model is clearly worse than the best for this profile."""
104
+ return self.gap > ALLOWED_GAP
105
+
106
+ def to_dict(self) -> JSONObject:
107
+ return {
108
+ "profile": self.profile.value,
109
+ "model": self.model,
110
+ "score": self.score,
111
+ "best_model": self.best_model,
112
+ "best_score": self.best_score,
113
+ "best_needs_gb": round(self.best_needs_gb, 1),
114
+ "needs_gb": round(self.needs_gb, 1),
115
+ "fits": self.fits,
116
+ "gap": self.gap,
117
+ }
118
+
119
+
120
+ def choose_model(
121
+ profile: Profile, free_ram_gb: float, edges: EdgeMode, megapixels: float
122
+ ) -> ModelChoice:
123
+ """The best-scoring model for `profile` that fits in `free_ram_gb`.
124
+
125
+ Ties go to the lighter model. If nothing fits, the lightest one is returned
126
+ and the plan's free-RAM check blocks the run with its real requirement.
127
+ """
128
+ needs = {
129
+ model: minimum_free_ram_gb(model, edges, megapixels) for model in PROFILE_SCORES[profile]
130
+ }
131
+ ranked = sorted(PROFILE_SCORES[profile].items(), key=lambda item: (-item[1], needs[item[0]]))
132
+ best_model, best_score = ranked[0]
133
+ fitting = [(model, score) for model, score in ranked if needs[model] <= free_ram_gb]
134
+ model, score = fitting[0] if fitting else min(ranked, key=lambda item: needs[item[0]])
135
+ return ModelChoice(
136
+ profile, model, score, best_model, best_score, needs[best_model], needs[model], free_ram_gb
137
+ )
@@ -0,0 +1,262 @@
1
+ """Phase 2 — plan: queue or parallel, how many workers, and whether it is safe to start.
2
+
3
+ Pure functions over the scan results, so every rule here is unit-tested.
4
+
5
+ Each worker process holds the whole model in RAM, so free RAM — not CPU — is
6
+ usually what caps parallelism. The run refuses to start below a free-RAM floor,
7
+ and while running it pauses dispatching new images whenever free RAM dips
8
+ below what one more image needs.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import math
14
+ from dataclasses import dataclass
15
+
16
+ from ..core.finish import NO_FINISH, Finish
17
+ from ..core.matting import MATTING_MAX_GB, MATTING_MIN_GB, EdgeMode, fullres_matting_gb
18
+ from ..domain import OutputFormat
19
+ from .events import JSONObject
20
+ from .scan import Hardware, Inventory
21
+
22
+ # Peak RSS per worker during inference (model + ONNX activations), measured on
23
+ # CPU with the engine's session options and edge matting off. Roughly constant
24
+ # per model: each model infers at a fixed size whatever the photo size.
25
+ MODEL_PEAK_GB: dict[str, float] = {
26
+ "birefnet-general-lite": 9.4,
27
+ "birefnet-general": 11.6,
28
+ "birefnet-portrait": 11.6,
29
+ "birefnet-dis": 11.6,
30
+ "birefnet-hrsod": 11.6,
31
+ "birefnet-massive": 11.6,
32
+ "isnet-general-use": 2.4,
33
+ "isnet-anime": 2.2,
34
+ "u2net": 1.8,
35
+ "u2net_human_seg": 1.8,
36
+ "silueta": 0.7,
37
+ }
38
+ UNKNOWN_MODEL_PEAK_GB = 12.0
39
+
40
+ # Full-resolution buffers per image (RGB, mask, RGBA).
41
+ IMAGE_GB_PER_MEGAPIXEL = 0.02
42
+ # Edge matting sizes itself to the RAM that is free when it runs, between
43
+ # MATTING_MIN_GB and MATTING_MAX_GB (see core/matting.py). The start floor
44
+ # counts the minimum; extra parallel workers must leave room for the maximum.
45
+
46
+ RESERVE_RAM_GB = 1.5 # kept free for the OS and other apps
47
+ # A worker that keeps more than this above its model's peak after an image is
48
+ # restarted, so heap fragmentation never stacks up across a long batch.
49
+ RECYCLE_MARGIN_GB = 1.0
50
+ # Each extra parallel worker must also leave this much headroom: two BiRefNet
51
+ # lite workers (each sized for full matting) need ~28 GB free to go parallel.
52
+ PARALLEL_HEADROOM_GB = 1.0
53
+ DEFAULT_MAX_WORKERS = 4
54
+
55
+ # Lossless RGBA output size per pixel (photos; conservative).
56
+ OUTPUT_BYTES_PER_PIXEL: dict[OutputFormat, float] = {
57
+ OutputFormat.PNG: 3.0,
58
+ OutputFormat.WEBP: 2.2,
59
+ OutputFormat.JPG: 0.8, # quality 95, no chroma subsampling
60
+ }
61
+ # Finishing (background, square, margin, size): the framed RGBA canvas, its
62
+ # resized copy and the flattened RGB, per megapixel of canvas.
63
+ FINISH_GB_PER_MEGAPIXEL = 0.012
64
+ DISK_MARGIN = 1.2
65
+ DISK_HEADROOM_GB = 0.2
66
+
67
+ # Measured seconds per image on an Apple M5 CPU (product photos, matting on);
68
+ # the live ETA refines this.
69
+ SECONDS_PER_IMAGE: dict[str, float] = {
70
+ "birefnet-general-lite": 3.2,
71
+ "birefnet-general": 5.6,
72
+ "birefnet-portrait": 5.5,
73
+ "birefnet-dis": 5.5,
74
+ "birefnet-hrsod": 5.6,
75
+ "birefnet-massive": 5.6,
76
+ "isnet-general-use": 1.3,
77
+ "isnet-anime": 1.2,
78
+ "u2net": 0.5,
79
+ "u2net_human_seg": 0.8,
80
+ "silueta": 0.4,
81
+ }
82
+ UNKNOWN_MODEL_SECONDS = 6.0
83
+ CUDA_SPEEDUP = 5.0
84
+ MATTING_SECONDS = 1.5
85
+ # A second CPU worker adds ~25% throughput (measured); more add little.
86
+ EXTRA_WORKER_SPEEDUP = 0.25
87
+
88
+
89
+ @dataclass(frozen=True, slots=True)
90
+ class PlanSettings:
91
+ model: str
92
+ output_format: OutputFormat
93
+ edges: EdgeMode = EdgeMode.MATTE
94
+ workers: int | None = None # explicit override
95
+ max_workers: int = DEFAULT_MAX_WORKERS
96
+ min_free_ram_gb: float | None = None # explicit override of the floor
97
+ finish: Finish = NO_FINISH
98
+
99
+
100
+ @dataclass(frozen=True, slots=True)
101
+ class Plan:
102
+ images: int
103
+ workers: int
104
+ threads_per_worker: int
105
+ binding_limit: str
106
+ ram_per_worker_gb: float
107
+ image_peak_gb: float
108
+ min_free_ram_gb: float
109
+ pause_below_gb: float
110
+ estimated_output_gb: float
111
+ free_disk_gb: float
112
+ eta_seconds: float
113
+ blockers: tuple[str, ...]
114
+ warnings: tuple[str, ...]
115
+
116
+ @property
117
+ def ok(self) -> bool:
118
+ return not self.blockers
119
+
120
+ @property
121
+ def mode(self) -> str:
122
+ return "parallel" if self.workers > 1 else "queue"
123
+
124
+ def to_dict(self) -> JSONObject:
125
+ return {
126
+ "images": self.images,
127
+ "mode": self.mode,
128
+ "workers": self.workers,
129
+ "threads_per_worker": self.threads_per_worker,
130
+ "binding_limit": self.binding_limit,
131
+ "ram_per_worker_gb": round(self.ram_per_worker_gb, 2),
132
+ "min_free_ram_gb": round(self.min_free_ram_gb, 1),
133
+ "pause_below_gb": round(self.pause_below_gb, 1),
134
+ "estimated_output_gb": round(self.estimated_output_gb, 2),
135
+ "free_disk_gb": round(self.free_disk_gb, 1),
136
+ "eta_seconds": round(self.eta_seconds),
137
+ "blockers": list(self.blockers),
138
+ "warnings": list(self.warnings),
139
+ }
140
+
141
+
142
+ def model_peak_gb(model: str) -> float:
143
+ return MODEL_PEAK_GB.get(model, UNKNOWN_MODEL_PEAK_GB)
144
+
145
+
146
+ def image_peak_gb(
147
+ max_megapixels: float,
148
+ edges: EdgeMode,
149
+ *,
150
+ full_matting: bool = False,
151
+ finish: Finish = NO_FINISH,
152
+ ) -> float:
153
+ """RAM for one image on top of the model: buffers plus edge matting.
154
+
155
+ The start floor counts matting at its minimum (it shrinks to fit); sizing
156
+ extra parallel workers counts it at its maximum.
157
+ """
158
+ buffers = max(0.2, max_megapixels * IMAGE_GB_PER_MEGAPIXEL)
159
+ if finish.active:
160
+ buffers += max_megapixels * finish.canvas_growth * FINISH_GB_PER_MEGAPIXEL
161
+ if edges is EdgeMode.MASK:
162
+ return buffers
163
+ solver = MATTING_MAX_GB if full_matting else MATTING_MIN_GB
164
+ return buffers + solver + fullres_matting_gb(max_megapixels, edges)
165
+
166
+
167
+ def minimum_free_ram_gb(
168
+ model: str, edges: EdgeMode = EdgeMode.MATTE, megapixels: float = 12.0
169
+ ) -> float:
170
+ """Free RAM one worker needs for photos up to `megapixels` (default: a 12 MP photo)."""
171
+ return model_peak_gb(model) + image_peak_gb(megapixels, edges) + RESERVE_RAM_GB
172
+
173
+
174
+ def estimate_eta_seconds(
175
+ model: str, device: str, images: int, workers: int, edges: EdgeMode
176
+ ) -> float:
177
+ per_image = SECONDS_PER_IMAGE.get(model, UNKNOWN_MODEL_SECONDS)
178
+ if device == "cuda":
179
+ per_image /= CUDA_SPEEDUP
180
+ if edges is not EdgeMode.MASK:
181
+ per_image += MATTING_SECONDS
182
+ return images * per_image / (1 + EXTRA_WORKER_SPEEDUP * (workers - 1))
183
+
184
+
185
+ def make_plan(
186
+ hardware: Hardware,
187
+ inventory: Inventory,
188
+ free_disk_gb: float,
189
+ settings: PlanSettings,
190
+ ) -> Plan:
191
+ images = len(inventory.sources)
192
+ blockers: list[str] = []
193
+ warnings: list[str] = []
194
+
195
+ peak = image_peak_gb(inventory.max_megapixels, settings.edges, finish=settings.finish)
196
+ floor = settings.min_free_ram_gb or model_peak_gb(settings.model) + peak + RESERVE_RAM_GB
197
+ # A worker with room for full-resolution matting: what each parallel worker needs.
198
+ full_peak = image_peak_gb(
199
+ inventory.max_megapixels, settings.edges, full_matting=True, finish=settings.finish
200
+ )
201
+ per_worker = model_peak_gb(settings.model) + full_peak
202
+
203
+ spare = hardware.ram_available_gb - (per_worker + RESERVE_RAM_GB)
204
+ by_ram = 1 + max(0, math.floor(spare / (per_worker + PARALLEL_HEADROOM_GB)))
205
+ # Each worker uses every performance core; a second worker still adds ~25%.
206
+ by_cpu = max(1, hardware.cpu_performance // 2)
207
+ limits = {
208
+ "RAM": by_ram,
209
+ "CPU": by_cpu,
210
+ "max workers": settings.max_workers,
211
+ "image count": max(1, images),
212
+ }
213
+ binding_limit, workers = min(limits.items(), key=lambda item: item[1])
214
+
215
+ if settings.workers is not None:
216
+ if settings.workers > by_ram:
217
+ warnings.append(
218
+ f"--workers {settings.workers} exceeds what free RAM fits ({by_ram}); "
219
+ "the run may pause often or swap."
220
+ )
221
+ workers, binding_limit = max(1, settings.workers), "--workers"
222
+
223
+ threads = max(2, hardware.cpu_performance)
224
+
225
+ if images == 0:
226
+ blockers.append("no supported images found")
227
+ if hardware.ram_available_gb < floor:
228
+ blockers.append(
229
+ f"free RAM {hardware.ram_available_gb:.1f} GB is below the "
230
+ f"{floor:.1f} GB this model needs — close some apps and retry"
231
+ )
232
+
233
+ total_pixels = inventory.total_megapixels * 1e6 * settings.finish.canvas_growth
234
+ if settings.finish.size is not None:
235
+ total_pixels = images * settings.finish.size**2
236
+ output_gb = total_pixels * OUTPUT_BYTES_PER_PIXEL[settings.output_format] / 1e9
237
+ if free_disk_gb < output_gb * DISK_MARGIN + DISK_HEADROOM_GB:
238
+ blockers.append(
239
+ f"not enough disk space: ~{output_gb:.2f} GB needed, {free_disk_gb:.2f} GB free"
240
+ )
241
+
242
+ if inventory.max_megapixels > 50:
243
+ warnings.append(
244
+ f"very large image ({inventory.max_megapixels:.0f} MP): expect high RAM use per image"
245
+ )
246
+
247
+ eta = estimate_eta_seconds(settings.model, hardware.device, images, workers, settings.edges)
248
+ return Plan(
249
+ images=images,
250
+ workers=workers,
251
+ threads_per_worker=threads,
252
+ binding_limit=binding_limit,
253
+ ram_per_worker_gb=per_worker,
254
+ image_peak_gb=full_peak,
255
+ min_free_ram_gb=floor,
256
+ pause_below_gb=RESERVE_RAM_GB,
257
+ estimated_output_gb=output_gb,
258
+ free_disk_gb=free_disk_gb,
259
+ eta_seconds=eta,
260
+ blockers=tuple(blockers),
261
+ warnings=tuple(warnings),
262
+ )