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.
- bulk_bg_remove/__init__.py +3 -0
- bulk_bg_remove/bulk/__init__.py +1 -0
- bulk_bg_remove/bulk/events.py +28 -0
- bulk_bg_remove/bulk/models.py +137 -0
- bulk_bg_remove/bulk/plan.py +262 -0
- bulk_bg_remove/bulk/runner.py +438 -0
- bulk_bg_remove/bulk/sample.py +165 -0
- bulk_bg_remove/bulk/scan.py +219 -0
- bulk_bg_remove/bulk/session.py +407 -0
- bulk_bg_remove/bulk/verify.py +89 -0
- bulk_bg_remove/cli.py +298 -0
- bulk_bg_remove/console.py +272 -0
- bulk_bg_remove/core/__init__.py +1 -0
- bulk_bg_remove/core/compositing.py +24 -0
- bulk_bg_remove/core/finish.py +171 -0
- bulk_bg_remove/core/io.py +122 -0
- bulk_bg_remove/core/matting.py +157 -0
- bulk_bg_remove/core/pipeline.py +94 -0
- bulk_bg_remove/doctor.py +107 -0
- bulk_bg_remove/domain.py +15 -0
- bulk_bg_remove/engine/__init__.py +1 -0
- bulk_bg_remove/engine/model.py +96 -0
- bulk_bg_remove/formats.py +74 -0
- bulk_bg_remove/paths.py +77 -0
- bulk_bg_remove/ui/__init__.py +1 -0
- bulk_bg_remove/ui/launch.py +46 -0
- bulk_bg_remove/ui/server.py +401 -0
- bulk_bg_remove/ui/static/index.html +452 -0
- bulk_bg_remove-0.1.0.dist-info/METADATA +180 -0
- bulk_bg_remove-0.1.0.dist-info/RECORD +33 -0
- bulk_bg_remove-0.1.0.dist-info/WHEEL +4 -0
- bulk_bg_remove-0.1.0.dist-info/entry_points.txt +2 -0
- bulk_bg_remove-0.1.0.dist-info/licenses/LICENSE +21 -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
|
+
)
|