Philippe Potvin commited on
Commit
ff87ef3
·
1 Parent(s): 8e32c2c

Sun, 28 Jun 2026 04:18 - Add enhance stage

Browse files
Files changed (4) hide show
  1. README.md +2 -2
  2. app.py +165 -2
  3. requirements.txt +2 -0
  4. tests/test_enhance_stage.py +45 -0
README.md CHANGED
@@ -8,10 +8,10 @@ sdk_version: 5.49.1
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
- version: 0.1.2
12
  short_description: Powerful image editing - supports one or two input images.
13
  ---
14
 
15
- Pro Realism Edit Studio is a powerful image editor powered by [Qwen-Image-Edit-2511](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) with [Phr00t's Rapid-AIO v23](https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO) accelerated transformer for 4-step inference. Upload one or two input images, write a prompt, get high-quality results.
16
 
17
  Video generation is disabled unless an owned Gradio video Space is configured with `VIDEO_SPACE_ID`.
 
8
  app_file: app.py
9
  pinned: false
10
  license: apache-2.0
11
+ version: 0.1.3
12
  short_description: Powerful image editing - supports one or two input images.
13
  ---
14
 
15
+ Pro Realism Edit Studio is a powerful image editor powered by [Qwen-Image-Edit-2511](https://huggingface.co/Qwen/Qwen-Image-Edit-2511) with [Phr00t's Rapid-AIO v23](https://huggingface.co/Phr00t/Qwen-Image-Edit-Rapid-AIO) accelerated transformer for 4-step inference. Upload one or two input images, write a prompt, get high-quality results. Optional Enhance modes add lazy Nomos ATD upscaling, masked skin cleanup, and final detail grain.
16
 
17
  Video generation is disabled unless an owned Gradio video Space is configured with `VIDEO_SPACE_ID`.
app.py CHANGED
@@ -6,7 +6,7 @@ import spaces
6
 
7
  from accelerate import init_empty_weights
8
  from collections import OrderedDict
9
- from PIL import Image
10
  from diffusers.models import QwenImageTransformer2DModel as DiffusersQwenImageTransformer2DModel
11
  from diffusers.models.model_loading_utils import load_model_dict_into_meta
12
  from huggingface_hub import hf_hub_download
@@ -22,7 +22,7 @@ from gradio_client import Client, handle_file
22
  import tempfile
23
 
24
  BASE_MODEL_ID = "Qwen/Qwen-Image-Edit-2511"
25
- APP_VERSION = "0.1.2"
26
  PHR00T_REPO_ID = os.environ.get("PHR00T_REPO_ID", "Phr00t/Qwen-Image-Edit-Rapid-AIO").strip()
27
  RAPID_TRANSFORMER_FILENAME = os.environ.get(
28
  "RAPID_TRANSFORMER_FILENAME",
@@ -30,6 +30,20 @@ RAPID_TRANSFORMER_FILENAME = os.environ.get(
30
  ).strip()
31
  PHR00T_TRANSFORMER_PREFIX = "model.diffusion_model."
32
  VIDEO_SPACE_ID = os.environ.get("VIDEO_SPACE_ID", "").strip()
 
 
 
 
 
 
 
 
 
 
 
 
 
 
33
 
34
  def turn_into_video(input_image, output_images, prompt, progress=gr.Progress(track_tqdm=True)):
35
  if not VIDEO_SPACE_ID:
@@ -179,6 +193,141 @@ pipe.transformer.set_attn_processor(QwenDoubleStreamAttnProcessorFA3())
179
  # --- UI Constants and Helpers ---
180
  MAX_SEED = np.iinfo(np.int32).max
181
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
182
  def use_output_as_input(output_images):
183
  """Move the first output image into the Image 1 slot."""
184
  if not output_images:
@@ -200,6 +349,7 @@ def infer(
200
  num_inference_steps=4,
201
  height=None,
202
  width=None,
 
203
  num_images_per_prompt=1,
204
  progress=gr.Progress(track_tqdm=True),
205
  ):
@@ -249,6 +399,12 @@ def infer(
249
  num_images_per_prompt=num_images_per_prompt,
250
  ).images
251
 
 
 
 
 
 
 
252
  # Save images to temporary files for proper serving
253
  output_paths = []
254
  os.makedirs("outputs", exist_ok=True)
@@ -306,6 +462,12 @@ with gr.Blocks(css=css) as demo:
306
  show_label=True,
307
  placeholder="Enter your prompt here...",
308
  )
 
 
 
 
 
 
309
  run_button = gr.Button("Edit!", variant="primary")
310
 
311
  with gr.Accordion("Advanced Settings", open=False):
@@ -392,6 +554,7 @@ with gr.Blocks(css=css) as demo:
392
  num_inference_steps,
393
  height,
394
  width,
 
395
  ],
396
  outputs=[result, seed, use_output_btn, turn_video_btn],
397
 
 
6
 
7
  from accelerate import init_empty_weights
8
  from collections import OrderedDict
9
+ from PIL import Image, ImageEnhance, ImageFilter
10
  from diffusers.models import QwenImageTransformer2DModel as DiffusersQwenImageTransformer2DModel
11
  from diffusers.models.model_loading_utils import load_model_dict_into_meta
12
  from huggingface_hub import hf_hub_download
 
22
  import tempfile
23
 
24
  BASE_MODEL_ID = "Qwen/Qwen-Image-Edit-2511"
25
+ APP_VERSION = "0.1.3"
26
  PHR00T_REPO_ID = os.environ.get("PHR00T_REPO_ID", "Phr00t/Qwen-Image-Edit-Rapid-AIO").strip()
27
  RAPID_TRANSFORMER_FILENAME = os.environ.get(
28
  "RAPID_TRANSFORMER_FILENAME",
 
30
  ).strip()
31
  PHR00T_TRANSFORMER_PREFIX = "model.diffusion_model."
32
  VIDEO_SPACE_ID = os.environ.get("VIDEO_SPACE_ID", "").strip()
33
+ UPSCALER_MODEL_ID = os.environ.get("UPSCALER_MODEL_ID", "Phips/4xNomos8k_atd_jpg").strip()
34
+ UPSCALER_MODEL_FILENAME = os.environ.get("UPSCALER_MODEL_FILENAME", "4xNomos8k_atd_jpg.safetensors").strip()
35
+ UPSCALER_TILE_SIZE = int(os.environ.get("UPSCALER_TILE_SIZE", "512"))
36
+ UPSCALER_TILE_OVERLAP = int(os.environ.get("UPSCALER_TILE_OVERLAP", "32"))
37
+ ENHANCE_MAX_INPUT_EDGE = int(os.environ.get("ENHANCE_MAX_INPUT_EDGE", "1280"))
38
+ ENHANCE_GRAIN_STRENGTH = float(os.environ.get("ENHANCE_GRAIN_STRENGTH", "0.018"))
39
+
40
+ ENHANCE_MODE_OFF = "Off"
41
+ ENHANCE_MODE_UPSCALE = "Upscale"
42
+ ENHANCE_MODE_CLEAN = "Clean"
43
+ ENHANCE_MODE_MAX_DETAIL = "Max Detail"
44
+ ENHANCE_MODE_CHOICES = [ENHANCE_MODE_OFF, ENHANCE_MODE_UPSCALE, ENHANCE_MODE_CLEAN, ENHANCE_MODE_MAX_DETAIL]
45
+
46
+ _upscaler_model = None
47
 
48
  def turn_into_video(input_image, output_images, prompt, progress=gr.Progress(track_tqdm=True)):
49
  if not VIDEO_SPACE_ID:
 
193
  # --- UI Constants and Helpers ---
194
  MAX_SEED = np.iinfo(np.int32).max
195
 
196
+
197
+ def load_upscaler_model():
198
+ global _upscaler_model
199
+ if _upscaler_model is not None:
200
+ return _upscaler_model
201
+
202
+ try:
203
+ import spandrel
204
+ import spandrel_extra_arches
205
+ except ImportError as exc:
206
+ raise gr.Error("Enhance mode requires spandrel and spandrel_extra_arches to be installed.") from exc
207
+
208
+ spandrel_extra_arches.install()
209
+ model_path = hf_hub_download(repo_id=UPSCALER_MODEL_ID, filename=UPSCALER_MODEL_FILENAME)
210
+ model = spandrel.ModelLoader().load_from_file(model_path)
211
+ model.eval().to(device)
212
+ _upscaler_model = model
213
+ return _upscaler_model
214
+
215
+
216
+ def image_to_tensor(image):
217
+ array = np.asarray(image.convert("RGB")).astype(np.float32) / 255.0
218
+ tensor = torch.from_numpy(array).permute(2, 0, 1).unsqueeze(0)
219
+ return tensor.to(device)
220
+
221
+
222
+ def tensor_to_image(tensor):
223
+ array = tensor.squeeze(0).detach().float().cpu().clamp(0, 1).permute(1, 2, 0).numpy()
224
+ return Image.fromarray((array * 255.0).round().astype(np.uint8), mode="RGB")
225
+
226
+
227
+ def validate_enhance_input_size(image):
228
+ max_edge = max(image.size)
229
+ if max_edge > ENHANCE_MAX_INPUT_EDGE:
230
+ raise gr.Error(
231
+ f"Enhance mode accepts images up to {ENHANCE_MAX_INPUT_EDGE}px on the longest edge. "
232
+ f"Current image is {image.width}x{image.height}."
233
+ )
234
+
235
+
236
+ def tile_upscale(image):
237
+ validate_enhance_input_size(image)
238
+ model = load_upscaler_model()
239
+ tensor = image_to_tensor(image)
240
+ _, _, height, width = tensor.shape
241
+ tile_size = max(64, min(UPSCALER_TILE_SIZE, height, width))
242
+ overlap = max(0, min(UPSCALER_TILE_OVERLAP, tile_size // 2))
243
+ step = max(1, tile_size - overlap)
244
+ y_positions = sorted(set(list(range(0, height, step)) + [max(0, height - tile_size)]))
245
+ x_positions = sorted(set(list(range(0, width, step)) + [max(0, width - tile_size)]))
246
+ output = None
247
+ weights = None
248
+
249
+ with torch.inference_mode():
250
+ for y in y_positions:
251
+ for x in x_positions:
252
+ y1 = min(y + tile_size, height)
253
+ x1 = min(x + tile_size, width)
254
+ tile = tensor[:, :, y:y1, x:x1]
255
+ upscaled_tile = model(tile).clamp(0, 1)
256
+ scale_y = upscaled_tile.shape[-2] // tile.shape[-2]
257
+ scale_x = upscaled_tile.shape[-1] // tile.shape[-1]
258
+ if output is None:
259
+ output = torch.zeros(
260
+ (1, 3, height * scale_y, width * scale_x),
261
+ dtype=upscaled_tile.dtype,
262
+ device=upscaled_tile.device,
263
+ )
264
+ weights = torch.zeros_like(output)
265
+ oy0, oy1 = y * scale_y, y1 * scale_y
266
+ ox0, ox1 = x * scale_x, x1 * scale_x
267
+ output[:, :, oy0:oy1, ox0:ox1] += upscaled_tile
268
+ weights[:, :, oy0:oy1, ox0:ox1] += 1
269
+
270
+ output = output / weights.clamp_min(1)
271
+ return tensor_to_image(output)
272
+
273
+
274
+ def skin_repair_mask(image):
275
+ ycbcr = np.asarray(image.convert("YCbCr"))
276
+ cb = ycbcr[:, :, 1]
277
+ cr = ycbcr[:, :, 2]
278
+ mask = (
279
+ (cr >= 135)
280
+ & (cr <= 180)
281
+ & (cb >= 75)
282
+ & (cb <= 135)
283
+ ).astype(np.uint8) * 255
284
+ mask_image = Image.fromarray(mask, mode="L")
285
+ return mask_image.filter(ImageFilter.GaussianBlur(radius=1.2))
286
+
287
+
288
+ def repair_skin_texture(image):
289
+ base = image.convert("RGB")
290
+ mask = skin_repair_mask(base)
291
+ repaired = base.filter(ImageFilter.MedianFilter(size=3)).filter(ImageFilter.GaussianBlur(radius=0.35))
292
+ blended = Image.composite(repaired, base, mask)
293
+ return ImageEnhance.Sharpness(blended).enhance(1.08)
294
+
295
+
296
+ def add_film_grain(image, seed):
297
+ base = image.convert("RGB")
298
+ array = np.asarray(base).astype(np.float32)
299
+ rng = np.random.default_rng(seed)
300
+ grain = rng.normal(0.0, 255.0 * ENHANCE_GRAIN_STRENGTH, size=(array.shape[0], array.shape[1], 1))
301
+ array = np.clip(array + grain, 0, 255)
302
+ return Image.fromarray(array.astype(np.uint8), mode="RGB")
303
+
304
+
305
+ def apply_enhancement(image, enhance_mode, seed=0, progress=None):
306
+ mode = enhance_mode or ENHANCE_MODE_OFF
307
+ if mode not in ENHANCE_MODE_CHOICES:
308
+ raise gr.Error(f"Unknown enhance mode: {mode}")
309
+ if mode == ENHANCE_MODE_OFF:
310
+ return image
311
+
312
+ enhanced = image.convert("RGB")
313
+ if mode in (ENHANCE_MODE_CLEAN, ENHANCE_MODE_MAX_DETAIL):
314
+ if progress:
315
+ progress(0.76, desc="Repairing skin texture...")
316
+ enhanced = repair_skin_texture(enhanced)
317
+
318
+ if mode in (ENHANCE_MODE_UPSCALE, ENHANCE_MODE_CLEAN, ENHANCE_MODE_MAX_DETAIL):
319
+ if progress:
320
+ progress(0.82, desc="Upscaling image...")
321
+ enhanced = tile_upscale(enhanced)
322
+
323
+ if mode == ENHANCE_MODE_MAX_DETAIL:
324
+ if progress:
325
+ progress(0.92, desc="Adding final grain and sharpness...")
326
+ enhanced = ImageEnhance.Sharpness(enhanced).enhance(1.12)
327
+ enhanced = add_film_grain(enhanced, seed)
328
+
329
+ return enhanced
330
+
331
  def use_output_as_input(output_images):
332
  """Move the first output image into the Image 1 slot."""
333
  if not output_images:
 
349
  num_inference_steps=4,
350
  height=None,
351
  width=None,
352
+ enhance_mode=ENHANCE_MODE_OFF,
353
  num_images_per_prompt=1,
354
  progress=gr.Progress(track_tqdm=True),
355
  ):
 
399
  num_images_per_prompt=num_images_per_prompt,
400
  ).images
401
 
402
+ if enhance_mode != ENHANCE_MODE_OFF:
403
+ images_pil = [
404
+ apply_enhancement(img, enhance_mode, seed=seed + idx, progress=progress)
405
+ for idx, img in enumerate(images_pil)
406
+ ]
407
+
408
  # Save images to temporary files for proper serving
409
  output_paths = []
410
  os.makedirs("outputs", exist_ok=True)
 
462
  show_label=True,
463
  placeholder="Enter your prompt here...",
464
  )
465
+ enhance_mode = gr.Radio(
466
+ label="Enhance / Amelioration",
467
+ choices=ENHANCE_MODE_CHOICES,
468
+ value=ENHANCE_MODE_OFF,
469
+ interactive=True,
470
+ )
471
  run_button = gr.Button("Edit!", variant="primary")
472
 
473
  with gr.Accordion("Advanced Settings", open=False):
 
554
  num_inference_steps,
555
  height,
556
  width,
557
+ enhance_mode,
558
  ],
559
  outputs=[result, seed, use_output_btn, turn_video_btn],
560
 
requirements.txt CHANGED
@@ -9,3 +9,5 @@ kernels==0.11.0
9
  torchvision
10
  peft
11
  torchao==0.11.0
 
 
 
9
  torchvision
10
  peft
11
  torchao==0.11.0
12
+ spandrel
13
+ spandrel_extra_arches
tests/test_enhance_stage.py ADDED
@@ -0,0 +1,45 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from pathlib import Path
2
+ import unittest
3
+
4
+
5
+ ROOT = Path(__file__).resolve().parents[1]
6
+
7
+
8
+ def read_text(relative_path: str) -> str:
9
+ return (ROOT / relative_path).read_text(encoding="utf-8")
10
+
11
+
12
+ class EnhanceStageContractTest(unittest.TestCase):
13
+ def test_enhance_modes_match_product_contract(self):
14
+ app = read_text("app.py")
15
+
16
+ self.assertIn('ENHANCE_MODE_OFF = "Off"', app)
17
+ self.assertIn('ENHANCE_MODE_UPSCALE = "Upscale"', app)
18
+ self.assertIn('ENHANCE_MODE_CLEAN = "Clean"', app)
19
+ self.assertIn('ENHANCE_MODE_MAX_DETAIL = "Max Detail"', app)
20
+ self.assertIn(
21
+ "ENHANCE_MODE_CHOICES = [ENHANCE_MODE_OFF, ENHANCE_MODE_UPSCALE, ENHANCE_MODE_CLEAN, ENHANCE_MODE_MAX_DETAIL]",
22
+ app,
23
+ )
24
+ self.assertIn("enhance_mode = gr.Radio", app)
25
+ self.assertIn("apply_enhancement", app)
26
+
27
+ def test_upscaler_is_lazy_and_configurable(self):
28
+ app = read_text("app.py")
29
+
30
+ self.assertIn("load_upscaler_model", app)
31
+ self.assertIn("UPSCALER_MODEL_ID", app)
32
+ self.assertIn("UPSCALER_MODEL_FILENAME", app)
33
+ self.assertIn("hf_hub_download(", app)
34
+ self.assertIn("spandrel", app)
35
+ self.assertIn("tile_upscale", app)
36
+
37
+ def test_upscaler_dependencies_are_declared(self):
38
+ requirements = read_text("requirements.txt")
39
+
40
+ self.assertIn("spandrel", requirements)
41
+ self.assertIn("spandrel_extra_arches", requirements)
42
+
43
+
44
+ if __name__ == "__main__":
45
+ unittest.main()