nmdbbb commited on
Commit
0fb4a38
·
1 Parent(s): 8dc9082

fix: sync inpainting.py/model_loader.py with main repo - deploy real StableDiffusionInpainter

Browse files

The Space had been running a stale LamaModelInpainter this whole time (the
crop-back-to-original-size fix for SimpleLama's multiple-of-8 padding was
never pushed here), which crashed with 'images do not match' on any crop
whose dimensions weren't already divisible by 8 - i.e. almost every real
photo, only missed by tiny square 256x256 test images. Also brings over
StableDiffusionInpainter, RemoteInpainter, proportional crop padding, and
CORS middleware from the main repo.

Files changed (3) hide show
  1. server/app.py +14 -1
  2. server/inpainting.py +161 -5
  3. server/model_loader.py +22 -4
server/app.py CHANGED
@@ -1,6 +1,7 @@
1
  import io
2
 
3
  from fastapi import FastAPI, File, HTTPException, UploadFile
 
4
  from PIL import Image
5
  from pydantic import BaseModel
6
 
@@ -38,6 +39,15 @@ def create_fastapi_app(
38
  session_store: SessionStore,
39
  ) -> FastAPI:
40
  app = FastAPI()
 
 
 
 
 
 
 
 
 
41
 
42
  @app.get("/health")
43
  def health():
@@ -72,7 +82,10 @@ def create_fastapi_app(
72
  pil_mask = Image.open(io.BytesIO(mask_bytes)).convert("L")
73
  if pil_mask.size != pil_image.size:
74
  pil_mask = pil_mask.resize(pil_image.size)
75
- result = composite_inpaint(pil_image, pil_mask, inpainter)
 
 
 
76
  return _png_response(result)
77
 
78
  return app
 
1
  import io
2
 
3
  from fastapi import FastAPI, File, HTTPException, UploadFile
4
+ from fastapi.middleware.cors import CORSMiddleware
5
  from PIL import Image
6
  from pydantic import BaseModel
7
 
 
39
  session_store: SessionStore,
40
  ) -> FastAPI:
41
  app = FastAPI()
42
+ # Lets the Flutter web build (served from a browser origin) call this
43
+ # API directly - browsers block cross-origin requests by default, and
44
+ # this server has no cookies/session-cookie auth to protect.
45
+ app.add_middleware(
46
+ CORSMiddleware,
47
+ allow_origins=["*"],
48
+ allow_methods=["*"],
49
+ allow_headers=["*"],
50
+ )
51
 
52
  @app.get("/health")
53
  def health():
 
82
  pil_mask = Image.open(io.BytesIO(mask_bytes)).convert("L")
83
  if pil_mask.size != pil_image.size:
84
  pil_mask = pil_mask.resize(pil_image.size)
85
+ if inpainter.does_own_compositing:
86
+ result = inpainter.inpaint(pil_image, pil_mask)
87
+ else:
88
+ result = composite_inpaint(pil_image, pil_mask, inpainter)
89
  return _png_response(result)
90
 
91
  return app
server/inpainting.py CHANGED
@@ -6,27 +6,93 @@ from PIL import Image, ImageFilter
6
  class Inpainter(ABC):
7
  """Fills the masked region of an image."""
8
 
 
 
 
 
 
 
 
 
9
  @abstractmethod
10
  def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
11
  """image and mask are the same size; mask 255 = area to fill. Returns a filled RGB image."""
12
 
13
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  def composite_inpaint(
15
  image: Image.Image,
16
  mask: Image.Image,
17
  inpainter: Inpainter,
18
  padding: int = 64,
19
  ) -> Image.Image:
20
- """Crop around the mask with padding, inpaint the crop, feather-blend it back into the full image."""
 
 
 
 
 
 
 
 
 
21
  bbox = mask.getbbox()
22
  if bbox is None:
23
  return image.copy()
24
 
25
  left, top, right, bottom = bbox
26
- left = max(0, left - padding)
27
- top = max(0, top - padding)
28
- right = min(image.width, right + padding)
29
- bottom = min(image.height, bottom + padding)
 
 
 
 
30
  crop_box = (left, top, right, bottom)
31
 
32
  image_crop = image.crop(crop_box)
@@ -66,5 +132,95 @@ class LamaModelInpainter(Inpainter):
66
 
67
  def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
68
  self._ensure_loaded()
 
69
  result = self._model(image.convert("RGB"), mask.convert("L"))
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
70
  return result.convert("RGB")
 
6
  class Inpainter(ABC):
7
  """Fills the masked region of an image."""
8
 
9
+ # Set True on an Inpainter whose inpaint() already does its own
10
+ # crop/composite around the mask (e.g. RemoteInpainter, which forwards
11
+ # to another server's full /inpaint pipeline) - callers should pass it
12
+ # the full image/mask directly instead of routing through
13
+ # composite_inpaint(), which would crop before calling it and cause a
14
+ # redundant, inconsistently-sized second crop/composite downstream.
15
+ does_own_compositing = False
16
+
17
  @abstractmethod
18
  def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
19
  """image and mask are the same size; mask 255 = area to fill. Returns a filled RGB image."""
20
 
21
 
22
+ class RemoteInpainter(Inpainter):
23
+ """Forwards inpainting to a remote server's /inpaint endpoint (e.g. the
24
+ HF Space) instead of running a local model.
25
+
26
+ Used for a hybrid self-host setup: SAM2 segmentation runs locally (light
27
+ enough for this machine's GPU), but Stable Diffusion inpainting - which
28
+ repeatedly crashed this machine's GPU driver even with reduced steps and
29
+ inter-step pacing - runs on the already-verified-stable cloud Space
30
+ instead.
31
+ """
32
+
33
+ # The remote /inpaint endpoint already crops around the mask, inpaints,
34
+ # and feather-composites the result back into the full image itself
35
+ # (see composite_inpaint below) - composite_inpaint() must not wrap this
36
+ # inpainter too, or the crop it hands to inpaint() gets composited a
37
+ # second time server-side, producing a differently-sized result that
38
+ # fails Image.composite()'s "images do not match" size check.
39
+ does_own_compositing = True
40
+
41
+ def __init__(self, base_url: str, timeout_seconds: float = 300.0):
42
+ self._base_url = base_url.rstrip("/")
43
+ self._timeout_seconds = timeout_seconds
44
+
45
+ def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
46
+ import io
47
+
48
+ import httpx
49
+
50
+ image_bytes = io.BytesIO()
51
+ image.convert("RGB").save(image_bytes, format="PNG")
52
+ mask_bytes = io.BytesIO()
53
+ mask.convert("L").save(mask_bytes, format="PNG")
54
+
55
+ response = httpx.post(
56
+ f"{self._base_url}/inpaint",
57
+ files={
58
+ "image": ("image.png", image_bytes.getvalue(), "image/png"),
59
+ "mask": ("mask.png", mask_bytes.getvalue(), "image/png"),
60
+ },
61
+ timeout=self._timeout_seconds,
62
+ )
63
+ response.raise_for_status()
64
+ return Image.open(io.BytesIO(response.content)).convert("RGB")
65
+
66
+
67
  def composite_inpaint(
68
  image: Image.Image,
69
  mask: Image.Image,
70
  inpainter: Inpainter,
71
  padding: int = 64,
72
  ) -> Image.Image:
73
+ """Crop around the mask with padding, inpaint the crop, feather-blend it back into the full image.
74
+
75
+ `padding` is a floor, not the actual amount used: the crop grows with
76
+ the masked object's own size (half its bounding-box width/height) so
77
+ LaMa always gets a ring of real background roughly as wide as the
78
+ object itself, not just a fixed 64px margin - a fixed-size margin
79
+ starves inpainting of surrounding context for anything bigger than a
80
+ small object, which shows up as a blurry/smudged fill instead of a
81
+ clean reconstruction.
82
+ """
83
  bbox = mask.getbbox()
84
  if bbox is None:
85
  return image.copy()
86
 
87
  left, top, right, bottom = bbox
88
+ object_width = right - left
89
+ object_height = bottom - top
90
+ horizontal_padding = max(padding, object_width // 2)
91
+ vertical_padding = max(padding, object_height // 2)
92
+ left = max(0, left - horizontal_padding)
93
+ top = max(0, top - vertical_padding)
94
+ right = min(image.width, right + horizontal_padding)
95
+ bottom = min(image.height, bottom + vertical_padding)
96
  crop_box = (left, top, right, bottom)
97
 
98
  image_crop = image.crop(crop_box)
 
132
 
133
  def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
134
  self._ensure_loaded()
135
+ original_size = image.size
136
  result = self._model(image.convert("RGB"), mask.convert("L"))
137
+ # SimpleLama pads the input up to a multiple of 8 internally and
138
+ # returns the padded size without cropping back down, so the result
139
+ # can be larger than the input - composite_inpaint() requires the
140
+ # returned image to match the original crop's size exactly.
141
+ if result.size != original_size:
142
+ result = result.crop((0, 0, original_size[0], original_size[1]))
143
+ return result.convert("RGB")
144
+
145
+
146
+ class StableDiffusionInpainter(Inpainter):
147
+ """Generative inpainting via Stable Diffusion 1.5's inpainting checkpoint.
148
+
149
+ Produces noticeably better results than LaMa on cluttered/detailed
150
+ backgrounds (patterned wallpaper, text, shelves of objects) - LaMa is a
151
+ single discriminative pass that falls back to a blurry average fill
152
+ when it can't confidently reconstruct fine detail, while SD generatively
153
+ hallucinates plausible texture. Trades speed for quality: expect several
154
+ seconds per removal on a modern GPU, and it needs a real GPU (~4-5GB
155
+ VRAM) to run at a usable speed at all.
156
+ """
157
+
158
+ def __init__(
159
+ self,
160
+ device: str = "cuda",
161
+ model_id: str = "runwayml/stable-diffusion-inpainting",
162
+ working_size: tuple[int, int] = (512, 512),
163
+ num_inference_steps: int = 30,
164
+ step_pause_seconds: float = 0.0,
165
+ ):
166
+ self._device = device
167
+ self._model_id = model_id
168
+ self._working_size = working_size
169
+ self._num_inference_steps = num_inference_steps
170
+ # A gaming GPU's own workload (frame rendering) naturally pulses -
171
+ # short bursts of full load separated by idle gaps - which is why it
172
+ # can sustain high clocks without the rapid thermal spikes seen
173
+ # under uninterrupted ML compute (e.g. 73C -> 83C in ~2s observed on
174
+ # this machine's laptop GPU during SAM2/SD inference). Sleeping
175
+ # briefly between denoising steps recreates that pulsed pattern in
176
+ # software when a real fix (proper power/thermal limiting) isn't
177
+ # available - set via SD_STEP_PAUSE_SECONDS for self-hosted local
178
+ # runs; leave at 0 (no pause) for ZeroGPU, where calls are billed by
179
+ # wall-clock time and the cloud GPU doesn't have this problem.
180
+ self._step_pause_seconds = step_pause_seconds
181
+ self._pipe = None
182
+
183
+ def _ensure_loaded(self) -> None:
184
+ if self._pipe is not None:
185
+ return
186
+ import torch
187
+ from diffusers import AutoPipelineForInpainting
188
+
189
+ dtype = torch.float16 if self._device.startswith("cuda") else torch.float32
190
+ pipe = AutoPipelineForInpainting.from_pretrained(self._model_id, torch_dtype=dtype)
191
+ pipe = pipe.to(self._device)
192
+ pipe.set_progress_bar_config(disable=True)
193
+ self._pipe = pipe
194
+
195
+ def inpaint(self, image: Image.Image, mask: Image.Image) -> Image.Image:
196
+ self._ensure_loaded()
197
+ original_size = image.size
198
+
199
+ # SD1.5 was trained at 512x512; run inference at that scale
200
+ # regardless of crop size and resize the result back afterwards -
201
+ # composite_inpaint() requires the returned image to match the
202
+ # original crop's exact size.
203
+ image_resized = image.convert("RGB").resize(self._working_size, Image.LANCZOS)
204
+ mask_resized = mask.convert("L").resize(self._working_size, Image.NEAREST)
205
+
206
+ callback = None
207
+ if self._step_pause_seconds > 0:
208
+ import time
209
+
210
+ def callback(pipe, step_index, timestep, callback_kwargs):
211
+ time.sleep(self._step_pause_seconds)
212
+ return callback_kwargs
213
+
214
+ result = self._pipe(
215
+ prompt="",
216
+ image=image_resized,
217
+ mask_image=mask_resized,
218
+ height=self._working_size[1],
219
+ width=self._working_size[0],
220
+ num_inference_steps=self._num_inference_steps,
221
+ callback_on_step_end=callback,
222
+ ).images[0]
223
+
224
+ if result.size != original_size:
225
+ result = result.resize(original_size, Image.LANCZOS)
226
  return result.convert("RGB")
server/model_loader.py CHANGED
@@ -19,9 +19,27 @@ def get_segmenter():
19
  def get_inpainter():
20
  global _inpainter
21
  if _inpainter is None:
22
- from server.inpainting import LamaModelInpainter
 
23
 
24
- _inpainter = LamaModelInpainter(
25
- device=os.environ.get("INFERENCE_DEVICE", "cuda"),
26
- )
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
27
  return _inpainter
 
19
  def get_inpainter():
20
  global _inpainter
21
  if _inpainter is None:
22
+ device = os.environ.get("INFERENCE_DEVICE", "cuda")
23
+ backend = os.environ.get("INPAINT_BACKEND", "sd") # "sd" or "lama"
24
 
25
+ if backend == "lama":
26
+ from server.inpainting import LamaModelInpainter
27
+
28
+ _inpainter = LamaModelInpainter(device=device)
29
+ elif backend == "remote":
30
+ from server.inpainting import RemoteInpainter
31
+
32
+ _inpainter = RemoteInpainter(
33
+ base_url=os.environ["REMOTE_INPAINT_URL"],
34
+ timeout_seconds=float(os.environ.get("REMOTE_INPAINT_TIMEOUT_SECONDS", "300")),
35
+ )
36
+ else:
37
+ from server.inpainting import StableDiffusionInpainter
38
+
39
+ _inpainter = StableDiffusionInpainter(
40
+ device=device,
41
+ model_id=os.environ.get("SD_INPAINT_MODEL_ID", "runwayml/stable-diffusion-inpainting"),
42
+ step_pause_seconds=float(os.environ.get("SD_STEP_PAUSE_SECONDS", "0")),
43
+ num_inference_steps=int(os.environ.get("SD_NUM_INFERENCE_STEPS", "30")),
44
+ )
45
  return _inpainter