jasong commited on
Commit
2bc2dad
·
1 Parent(s): 44edf77

feat: SAM2 for POM / F0 masking

Browse files
app.py CHANGED
@@ -19,6 +19,30 @@ from chord.util import get_positions, rgb_to_srgb
19
  from chord.io import load_torch_file
20
  from chord.minecraft_pbr import convert_to_labpbr, convert_to_bedrock, LABPBR_METAL_CHOICES
21
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
22
 
23
  def _load_examples(directory: str) -> list:
24
  """Load example images from a directory, returning empty list if not found."""
@@ -84,6 +108,359 @@ def relit(model, maps):
84
  rgb = model.model.compute_render(maps, camera, pos, light).squeeze(0).permute(0,3,1,2) # GxBxHxWxC -> BxCxHxW
85
  return torch.clamp(rgb_to_srgb(rgb), 0, 1)
86
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
87
  @spaces.GPU
88
  def inference(
89
  img,
@@ -107,6 +484,8 @@ def inference(
107
  emission_knee,
108
  emission_bloom,
109
  hardcoded_metal,
 
 
110
  ):
111
  """
112
  Run Chord model and output shader-compatible textures.
@@ -141,6 +520,29 @@ def inference(
141
  roughness = resize_back(out["roughness"].unsqueeze(0) if out["roughness"].dim() == 2 else out["roughness"])
142
  metalness = resize_back(out["metalness"].unsqueeze(0) if out["metalness"].dim() == 2 else out["metalness"])
143
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
144
  if output_format == "bedrock":
145
  # Convert to Bedrock RTX format (MER/MERS)
146
  result = convert_to_bedrock(
@@ -176,6 +578,7 @@ def inference(
176
  height_mid_freq=height_mid_freq,
177
  height_high_freq=height_high_freq,
178
  height_intensity=height_intensity,
 
179
  seamless=seamless,
180
  ao_strength=ao_strength,
181
  ao_blur=int(ao_blur),
@@ -190,6 +593,7 @@ def inference(
190
  emission_knee=emission_knee,
191
  emission_bloom=int(emission_bloom),
192
  hardcoded_metal=hardcoded_metal,
 
193
  )
194
  return (
195
  result['albedo'],
@@ -278,6 +682,127 @@ Upload an image to estimate PBR materials and export for Minecraft shaders.
278
  info="Use predefined metal F0 values (230-237) for metallic areas"
279
  )
280
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
281
  gr.Markdown("### Example Inputs — Generated Textures")
282
  gr.Examples(
283
  examples=EXAMPLES_USECASE_1,
@@ -310,6 +835,122 @@ Upload an image to estimate PBR materials and export for Minecraft shaders.
310
  gr.Markdown("### Preview")
311
  render_out = gr.Image(label="Relit Preview (Point Light)", height=340, format="png")
312
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
313
  run_button.click(
314
  inference,
315
  inputs=[
@@ -334,6 +975,8 @@ Upload an image to estimate PBR materials and export for Minecraft shaders.
334
  emission_knee,
335
  emission_bloom,
336
  hardcoded_metal,
 
 
337
  ],
338
  outputs=[albedo_out, packed_out, normal_out, render_out]
339
  )
 
19
  from chord.io import load_torch_file
20
  from chord.minecraft_pbr import convert_to_labpbr, convert_to_bedrock, LABPBR_METAL_CHOICES
21
 
22
+ # Try to import SAM 2 - it's optional
23
+ SAM_AVAILABLE = False
24
+ try:
25
+ from chord.sam_segmenter import SAM2Segmenter, create_mask_overlay, draw_points_on_image
26
+ SAM_AVAILABLE = True
27
+ print("SAM 2 segmenter available")
28
+ except ImportError as e:
29
+ print(f"SAM 2 not available: {e}")
30
+ print("Install with: pip install sam2")
31
+ # Create dummy functions so the app doesn't crash
32
+ SAM2Segmenter = None
33
+ def create_mask_overlay(image, mask, color=(255, 100, 100), alpha=0.5):
34
+ return image
35
+ def draw_points_on_image(image, fg_points, bg_points=None, point_radius=6):
36
+ from PIL import ImageDraw
37
+ draw_img = image.copy()
38
+ draw = ImageDraw.Draw(draw_img)
39
+ r = point_radius
40
+ for x, y in (fg_points or []):
41
+ draw.ellipse([x - r, y - r, x + r, y + r], fill=(0, 255, 0), outline=(0, 180, 0), width=2)
42
+ for x, y in (bg_points or []):
43
+ draw.ellipse([x - r, y - r, x + r, y + r], fill=(255, 0, 0), outline=(180, 0, 0), width=2)
44
+ return draw_img
45
+
46
 
47
  def _load_examples(directory: str) -> list:
48
  """Load example images from a directory, returning empty list if not found."""
 
108
  rgb = model.model.compute_render(maps, camera, pos, light).squeeze(0).permute(0,3,1,2) # GxBxHxWxC -> BxCxHxW
109
  return torch.clamp(rgb_to_srgb(rgb), 0, 1)
110
 
111
+
112
+ # =============================================================================
113
+ # SAM 2 Segmentation Helpers
114
+ # =============================================================================
115
+
116
+ # Global cache for SAM2 model (loaded once, moved to GPU as needed)
117
+ _SAM2_MODEL = None
118
+
119
+
120
+ def _get_sam2_model():
121
+ """Get or create cached SAM2 model (on CPU for storage)."""
122
+ global _SAM2_MODEL
123
+ if _SAM2_MODEL is None:
124
+ print("Loading SAM 2 model (first time, will be cached)...")
125
+ from sam2.build_sam import build_sam2_hf
126
+ # Load to CPU first - will be moved to CUDA in @spaces.GPU function
127
+ _SAM2_MODEL = build_sam2_hf(
128
+ model_id="facebook/sam2.1-hiera-small",
129
+ device=torch.device("cpu")
130
+ )
131
+ print("SAM 2 model cached on CPU")
132
+ return _SAM2_MODEL
133
+
134
+
135
+ def on_image_upload_for_mask(image):
136
+ """Reset SAM state when new image is uploaded."""
137
+ # Returns: fg_points, bg_points, mask, mask_preview_image
138
+ return [], [], None, image
139
+
140
+
141
+ def feather_mask(mask, radius):
142
+ """Apply Gaussian blur to mask edges for soft feathering.
143
+
144
+ Args:
145
+ mask: Binary or soft mask as numpy array (H, W) with values 0-1
146
+ radius: Feather radius in pixels (0 = no feathering)
147
+
148
+ Returns:
149
+ Feathered mask with soft edges
150
+ """
151
+ if radius <= 0:
152
+ return mask
153
+
154
+ import numpy as np
155
+ from scipy.ndimage import gaussian_filter
156
+
157
+ # Gaussian blur creates soft edges
158
+ # Sigma is approximately radius/2 for natural-looking feather
159
+ sigma = radius / 2.0
160
+ feathered = gaussian_filter(mask.astype(np.float32), sigma=sigma)
161
+
162
+ return feathered
163
+
164
+
165
+ @spaces.GPU
166
+ def run_sam_prediction(image, fg_points, bg_points):
167
+ """Run SAM prediction on GPU. Must be in @spaces.GPU decorated function."""
168
+ import numpy as np
169
+ from sam2.sam2_image_predictor import SAM2ImagePredictor
170
+
171
+ # Get cached model and move to CUDA for this call
172
+ sam2_model = _get_sam2_model()
173
+ sam2_model = sam2_model.to("cuda")
174
+
175
+ predictor = SAM2ImagePredictor(sam2_model)
176
+
177
+ # Set image
178
+ image_np = np.array(image.convert("RGB"))
179
+ with torch.inference_mode():
180
+ predictor.set_image(image_np)
181
+
182
+ # Build point arrays
183
+ all_points = []
184
+ all_labels = []
185
+ for x, y in fg_points:
186
+ all_points.append([x, y])
187
+ all_labels.append(1)
188
+ if bg_points:
189
+ for x, y in bg_points:
190
+ all_points.append([x, y])
191
+ all_labels.append(0)
192
+
193
+ point_coords = np.array(all_points)
194
+ point_labels = np.array(all_labels)
195
+
196
+ # Predict
197
+ with torch.inference_mode(), torch.autocast("cuda", dtype=torch.bfloat16):
198
+ masks, scores, _ = predictor.predict(
199
+ point_coords=point_coords,
200
+ point_labels=point_labels,
201
+ multimask_output=True,
202
+ )
203
+
204
+ # Return best mask (numpy array, already on CPU)
205
+ best_idx = np.argmax(scores)
206
+ return masks[best_idx].astype(np.float32), float(scores[best_idx])
207
+
208
+
209
+ def on_mask_image_click(image, fg_points, bg_points, evt: gr.SelectData, point_mode, feather):
210
+ """Handle click on image for point annotation."""
211
+ import numpy as np
212
+
213
+ if image is None:
214
+ print("SAM: No image loaded")
215
+ return fg_points, bg_points, None, None
216
+
217
+ x, y = evt.index
218
+ print(f"SAM click at ({x}, {y}) - mode: {point_mode}, feather: {feather}")
219
+
220
+ if point_mode == "foreground":
221
+ fg_points = list(fg_points) + [(x, y)]
222
+ else:
223
+ bg_points = list(bg_points) + [(x, y)]
224
+
225
+ # Always draw points on preview so user sees their clicks
226
+ preview = draw_points_on_image(image, fg_points, bg_points)
227
+ mask = None
228
+
229
+ # Generate mask preview if we have foreground points
230
+ if fg_points:
231
+ try:
232
+ mask, score = run_sam_prediction(image, fg_points, bg_points)
233
+ print(f"SAM mask generated, score: {score:.3f}")
234
+
235
+ # Apply feathering to mask edges
236
+ mask = feather_mask(mask, int(feather))
237
+
238
+ # Create overlay visualization with mask
239
+ preview = create_mask_overlay(image, mask, color=(255, 100, 100), alpha=0.5)
240
+ preview = draw_points_on_image(preview, fg_points, bg_points)
241
+ except Exception as e:
242
+ print(f"SAM error: {e}")
243
+ import traceback
244
+ traceback.print_exc()
245
+ # Keep preview with just points drawn (already set above)
246
+
247
+ return fg_points, bg_points, mask, preview
248
+
249
+
250
+ def clear_mask_state(original_image):
251
+ """Clear all mask-related state."""
252
+ # Returns: fg_points, bg_points, mask, mask_preview_image
253
+ return [], [], None, original_image
254
+
255
+
256
+ def regenerate_mask_preview(image, fg_points, bg_points, feather):
257
+ """Regenerate mask preview with current points."""
258
+ if image is None:
259
+ return None, None
260
+
261
+ # Always draw points even without mask
262
+ preview = draw_points_on_image(image, fg_points, bg_points) if fg_points else image
263
+
264
+ if not fg_points:
265
+ return None, preview
266
+
267
+ try:
268
+ mask, score = run_sam_prediction(image, fg_points, bg_points)
269
+ print(f"SAM regenerate mask, score: {score:.3f}")
270
+
271
+ # Apply feathering to mask edges
272
+ mask = feather_mask(mask, int(feather))
273
+
274
+ preview = create_mask_overlay(image, mask, color=(255, 100, 100), alpha=0.5)
275
+ preview = draw_points_on_image(preview, fg_points, bg_points)
276
+
277
+ return mask, preview
278
+ except Exception as e:
279
+ print(f"SAM regenerate error: {e}")
280
+ import traceback
281
+ traceback.print_exc()
282
+ return None, preview
283
+
284
+
285
+ # =============================================================================
286
+ # Metal Mask SAM Helpers
287
+ # =============================================================================
288
+
289
+ # Color map for different metal types (for visualization)
290
+ METAL_COLORS = {
291
+ "iron": (180, 180, 180), # Gray
292
+ "gold": (255, 215, 0), # Gold
293
+ "aluminum": (200, 200, 210), # Light gray-blue
294
+ "chrome": (220, 220, 230), # Silver
295
+ "copper": (184, 115, 51), # Copper
296
+ "lead": (100, 100, 110), # Dark gray
297
+ "platinum": (229, 228, 226), # Platinum
298
+ "silver": (192, 192, 192), # Silver
299
+ "custom": (255, 100, 255), # Magenta for custom
300
+ }
301
+
302
+
303
+ def on_image_upload_for_metal_mask(image):
304
+ """Reset metal mask state when new image is uploaded."""
305
+ import numpy as np
306
+ # Returns: fg_points, bg_points, current_segment_mask, combined_metal_mask, preview
307
+ return [], [], None, None, image
308
+
309
+
310
+ def on_metal_mask_image_click(image, fg_points, bg_points, evt: gr.SelectData, point_mode, metal_type, feather):
311
+ """Handle click on image for metal mask point annotation."""
312
+ import numpy as np
313
+
314
+ if image is None:
315
+ print("Metal SAM: No image loaded")
316
+ return fg_points, bg_points, None, None
317
+
318
+ x, y = evt.index
319
+ print(f"Metal SAM click at ({x}, {y}) - mode: {point_mode}, metal: {metal_type}, feather: {feather}")
320
+
321
+ if point_mode == "foreground":
322
+ fg_points = list(fg_points) + [(x, y)]
323
+ else:
324
+ bg_points = list(bg_points) + [(x, y)]
325
+
326
+ # Always draw points on preview so user sees their clicks
327
+ preview = draw_points_on_image(image, fg_points, bg_points)
328
+ mask = None
329
+
330
+ # Generate mask preview if we have foreground points
331
+ if fg_points:
332
+ try:
333
+ mask, score = run_sam_prediction(image, fg_points, bg_points)
334
+ print(f"Metal SAM mask generated, score: {score:.3f}")
335
+
336
+ # Apply feathering to mask edges
337
+ mask = feather_mask(mask, int(feather))
338
+
339
+ # Get color for current metal type
340
+ color = METAL_COLORS.get(metal_type, (255, 100, 100))
341
+
342
+ # Create overlay visualization with mask
343
+ preview = create_mask_overlay(image, mask, color=color, alpha=0.5)
344
+ preview = draw_points_on_image(preview, fg_points, bg_points)
345
+ except Exception as e:
346
+ print(f"Metal SAM error: {e}")
347
+ import traceback
348
+ traceback.print_exc()
349
+ # Keep preview with just points drawn (already set above)
350
+
351
+ return fg_points, bg_points, mask, preview
352
+
353
+
354
+ def add_metal_region_to_mask(current_segment_mask, combined_metal_mask, metal_type, image_size):
355
+ """Add the current SAM segment to the combined metal mask with the selected metal type."""
356
+ import numpy as np
357
+ from chord.minecraft_pbr import LABPBR_METALS
358
+
359
+ if current_segment_mask is None:
360
+ return combined_metal_mask, "No segment to add. Click on the image to create a segment first."
361
+
362
+ # Get the metal ID value
363
+ metal_id = LABPBR_METALS.get(metal_type, 255)
364
+ if metal_id is None:
365
+ return combined_metal_mask, "Invalid metal type selected."
366
+
367
+ # Normalize to [0, 1] scale for the mask tensor
368
+ normalized_metal_value = metal_id / 255.0
369
+
370
+ # Initialize combined mask if needed
371
+ if combined_metal_mask is None:
372
+ combined_metal_mask = np.zeros(current_segment_mask.shape, dtype=np.float32)
373
+
374
+ # Add current segment with the metal value (overwrites existing values in the region)
375
+ combined_metal_mask = np.where(current_segment_mask > 0.5, normalized_metal_value, combined_metal_mask)
376
+
377
+ return combined_metal_mask, f"Added {metal_type} region (ID: {metal_id})"
378
+
379
+
380
+ def regenerate_metal_mask_preview(image, fg_points, bg_points, metal_type, feather):
381
+ """Regenerate metal mask preview with current points and feather setting."""
382
+ if image is None:
383
+ return fg_points, bg_points, None, None
384
+
385
+ # Always draw points even without mask
386
+ preview = draw_points_on_image(image, fg_points, bg_points) if fg_points else image
387
+
388
+ if not fg_points:
389
+ return fg_points, bg_points, None, preview
390
+
391
+ try:
392
+ mask, score = run_sam_prediction(image, fg_points, bg_points)
393
+ print(f"Metal SAM regenerate mask, score: {score:.3f}")
394
+
395
+ # Apply feathering to mask edges
396
+ mask = feather_mask(mask, int(feather))
397
+
398
+ # Get color for current metal type
399
+ color = METAL_COLORS.get(metal_type, (255, 100, 100))
400
+
401
+ preview = create_mask_overlay(image, mask, color=color, alpha=0.5)
402
+ preview = draw_points_on_image(preview, fg_points, bg_points)
403
+
404
+ return fg_points, bg_points, mask, preview
405
+ except Exception as e:
406
+ print(f"Metal SAM regenerate error: {e}")
407
+ import traceback
408
+ traceback.print_exc()
409
+ return fg_points, bg_points, None, preview
410
+
411
+
412
+ def clear_metal_mask_segment(original_image):
413
+ """Clear current segment points but keep combined mask."""
414
+ # Returns: fg_points, bg_points, current_segment_mask, preview (reset to original)
415
+ return [], [], None, original_image
416
+
417
+
418
+ def clear_all_metal_masks(original_image):
419
+ """Clear all metal masks including combined mask."""
420
+ # Returns: fg_points, bg_points, current_segment_mask, combined_metal_mask, preview
421
+ return [], [], None, None, original_image
422
+
423
+
424
+ def create_metal_mask_preview(image, combined_metal_mask):
425
+ """Create a preview visualization of all metal regions."""
426
+ import numpy as np
427
+ from PIL import Image as PILImage
428
+ from chord.minecraft_pbr import LABPBR_METALS
429
+
430
+ if image is None or combined_metal_mask is None:
431
+ return image
432
+
433
+ # Create inverse lookup: value -> metal name
434
+ value_to_metal = {v / 255.0: k for k, v in LABPBR_METALS.items() if v is not None}
435
+
436
+ # Convert image to numpy array
437
+ img_array = np.array(image).astype(np.float32)
438
+
439
+ # Create colored overlay for each metal type
440
+ overlay = np.zeros_like(img_array)
441
+ mask_active = combined_metal_mask > 0
442
+
443
+ # Find unique metal values in the mask
444
+ unique_values = np.unique(combined_metal_mask[mask_active])
445
+
446
+ for val in unique_values:
447
+ metal_name = value_to_metal.get(val, "custom")
448
+ color = METAL_COLORS.get(metal_name, (255, 100, 255))
449
+ region = np.abs(combined_metal_mask - val) < 0.01
450
+ for c in range(3):
451
+ overlay[:, :, c] = np.where(region, color[c], overlay[:, :, c])
452
+
453
+ # Blend overlay with original image
454
+ alpha = 0.5
455
+ result = np.where(
456
+ mask_active[:, :, np.newaxis],
457
+ img_array * (1 - alpha) + overlay * alpha,
458
+ img_array
459
+ )
460
+
461
+ return PILImage.fromarray(result.astype(np.uint8))
462
+
463
+
464
  @spaces.GPU
465
  def inference(
466
  img,
 
484
  emission_knee,
485
  emission_bloom,
486
  hardcoded_metal,
487
+ height_mask,
488
+ metal_mask,
489
  ):
490
  """
491
  Run Chord model and output shader-compatible textures.
 
520
  roughness = resize_back(out["roughness"].unsqueeze(0) if out["roughness"].dim() == 2 else out["roughness"])
521
  metalness = resize_back(out["metalness"].unsqueeze(0) if out["metalness"].dim() == 2 else out["metalness"])
522
 
523
+ # Get device from model output tensors
524
+ device = basecolor.device
525
+
526
+ # Convert height mask from numpy to tensor if provided
527
+ height_mask_tensor = None
528
+ if height_mask is not None:
529
+ import numpy as np
530
+ height_mask_tensor = torch.from_numpy(height_mask).float().to(device)
531
+ # Resize mask to match output resolution
532
+ if height_mask_tensor.dim() == 2:
533
+ height_mask_tensor = height_mask_tensor.unsqueeze(0).unsqueeze(0)
534
+ height_mask_tensor = v2.Resize(size=(ori_h, ori_w))(height_mask_tensor)
535
+
536
+ # Convert metal mask from numpy to tensor if provided
537
+ metal_mask_tensor = None
538
+ if metal_mask is not None:
539
+ import numpy as np
540
+ metal_mask_tensor = torch.from_numpy(metal_mask).float().to(device)
541
+ # Resize mask to match output resolution
542
+ if metal_mask_tensor.dim() == 2:
543
+ metal_mask_tensor = metal_mask_tensor.unsqueeze(0).unsqueeze(0)
544
+ metal_mask_tensor = v2.Resize(size=(ori_h, ori_w), interpolation=v2.InterpolationMode.NEAREST)(metal_mask_tensor)
545
+
546
  if output_format == "bedrock":
547
  # Convert to Bedrock RTX format (MER/MERS)
548
  result = convert_to_bedrock(
 
578
  height_mid_freq=height_mid_freq,
579
  height_high_freq=height_high_freq,
580
  height_intensity=height_intensity,
581
+ height_mask=height_mask_tensor,
582
  seamless=seamless,
583
  ao_strength=ao_strength,
584
  ao_blur=int(ao_blur),
 
593
  emission_knee=emission_knee,
594
  emission_bloom=int(emission_bloom),
595
  hardcoded_metal=hardcoded_metal,
596
+ metal_mask=metal_mask_tensor,
597
  )
598
  return (
599
  result['albedo'],
 
682
  info="Use predefined metal F0 values (230-237) for metallic areas"
683
  )
684
 
685
+ with gr.Accordion("POM Height Mask (Optional - SAM 2)", open=False):
686
+ gr.Markdown("""
687
+ Use SAM 2 to create a mask that flattens selected regions in the height map.
688
+ Click on the image below to add points:
689
+ - **Green points (Foreground)**: Include in mask - areas will be flattened
690
+ - **Red points (Background)**: Exclude from mask - refine the selection
691
+ """)
692
+
693
+ with gr.Row():
694
+ point_mode = gr.Radio(
695
+ choices=["foreground", "background"],
696
+ value="foreground",
697
+ label="Point Mode",
698
+ info="Foreground = add to mask (flatten), Background = exclude from mask"
699
+ )
700
+ mask_feather = gr.Slider(
701
+ minimum=0, maximum=50, value=0, step=1,
702
+ label="Feather",
703
+ info="Blur mask edges for soft transitions (0 = hard edges)"
704
+ )
705
+
706
+ with gr.Row():
707
+ mask_image = gr.Image(
708
+ type="pil",
709
+ label="Click to add points",
710
+ interactive=True,
711
+ height=256,
712
+ )
713
+ mask_preview = gr.Image(
714
+ type="pil",
715
+ label="Mask Preview (red = will be flattened)",
716
+ interactive=False,
717
+ height=256,
718
+ )
719
+
720
+ with gr.Row():
721
+ clear_mask_btn = gr.Button("Clear Mask", size="sm")
722
+ regenerate_btn = gr.Button("Regenerate Preview", size="sm")
723
+
724
+ # Hidden state components
725
+ fg_points_state = gr.State([])
726
+ bg_points_state = gr.State([])
727
+ current_mask_state = gr.State(None)
728
+
729
+ with gr.Accordion("Metal Type Mask (Optional - SAM 2, LabPBR only)", open=False):
730
+ gr.Markdown("""
731
+ Use SAM 2 to paint regions with specific LabPBR metal types (iron, gold, copper, etc.).
732
+ This overrides the global "Hardcoded Metal" setting for selected regions.
733
+
734
+ **Workflow:**
735
+ 1. Select a metal type from the dropdown
736
+ 2. Click on the image to add foreground points (areas to mark as this metal)
737
+ 3. Optionally add background points to refine the selection
738
+ 4. Click "Add Region" to save this metal region
739
+ 5. Repeat for other metal types if needed
740
+ """)
741
+
742
+ metal_type_selector = gr.Dropdown(
743
+ choices=[
744
+ ("Iron (230)", "iron"),
745
+ ("Gold (231)", "gold"),
746
+ ("Aluminum (232)", "aluminum"),
747
+ ("Chrome (233)", "chrome"),
748
+ ("Copper (234)", "copper"),
749
+ ("Lead (235)", "lead"),
750
+ ("Platinum (236)", "platinum"),
751
+ ("Silver (237)", "silver"),
752
+ ("Custom Metal (255)", "custom"),
753
+ ],
754
+ value="iron",
755
+ label="Metal Type to Paint",
756
+ info="Select the metal type before clicking on the image"
757
+ )
758
+
759
+ with gr.Row():
760
+ metal_point_mode = gr.Radio(
761
+ choices=["foreground", "background"],
762
+ value="foreground",
763
+ label="Point Mode",
764
+ info="Foreground = add to selection, Background = exclude from selection"
765
+ )
766
+ metal_mask_feather = gr.Slider(
767
+ minimum=0, maximum=50, value=0, step=1,
768
+ label="Feather",
769
+ info="Blur mask edges for soft transitions (0 = hard edges)"
770
+ )
771
+
772
+ with gr.Row():
773
+ metal_mask_image = gr.Image(
774
+ type="pil",
775
+ label="Click to select metal regions",
776
+ interactive=True,
777
+ height=256,
778
+ )
779
+ metal_mask_preview = gr.Image(
780
+ type="pil",
781
+ label="Current Selection Preview",
782
+ interactive=False,
783
+ height=256,
784
+ )
785
+
786
+ with gr.Row():
787
+ add_metal_region_btn = gr.Button("Add Region", variant="primary", size="sm")
788
+ clear_metal_segment_btn = gr.Button("Clear Selection", size="sm")
789
+ clear_all_metals_btn = gr.Button("Clear All Metals", size="sm")
790
+
791
+ metal_status = gr.Textbox(label="Status", interactive=False, value="No metal regions defined")
792
+
793
+ combined_metal_preview = gr.Image(
794
+ type="pil",
795
+ label="Combined Metal Mask (all regions)",
796
+ interactive=False,
797
+ height=256,
798
+ )
799
+
800
+ # Hidden state components for metal mask
801
+ metal_fg_points_state = gr.State([])
802
+ metal_bg_points_state = gr.State([])
803
+ metal_current_segment_state = gr.State(None)
804
+ metal_combined_mask_state = gr.State(None)
805
+
806
  gr.Markdown("### Example Inputs — Generated Textures")
807
  gr.Examples(
808
  examples=EXAMPLES_USECASE_1,
 
835
  gr.Markdown("### Preview")
836
  render_out = gr.Image(label="Relit Preview (Point Light)", height=340, format="png")
837
 
838
+ # ==========================================================================
839
+ # SAM 2 Event Handlers
840
+ # ==========================================================================
841
+
842
+ # Sync input image to mask editor when uploaded
843
+ input_img.change(
844
+ fn=on_image_upload_for_mask,
845
+ inputs=[input_img],
846
+ outputs=[fg_points_state, bg_points_state, current_mask_state, mask_image]
847
+ )
848
+
849
+ # Handle clicks on mask image for point annotation
850
+ mask_image.select(
851
+ fn=on_mask_image_click,
852
+ inputs=[mask_image, fg_points_state, bg_points_state, point_mode, mask_feather],
853
+ outputs=[fg_points_state, bg_points_state, current_mask_state, mask_preview]
854
+ )
855
+
856
+ # Clear mask button
857
+ clear_mask_btn.click(
858
+ fn=clear_mask_state,
859
+ inputs=[input_img],
860
+ outputs=[fg_points_state, bg_points_state, current_mask_state, mask_preview]
861
+ )
862
+
863
+ # Regenerate mask preview button (also updates when feather changes)
864
+ regenerate_btn.click(
865
+ fn=regenerate_mask_preview,
866
+ inputs=[mask_image, fg_points_state, bg_points_state, mask_feather],
867
+ outputs=[current_mask_state, mask_preview]
868
+ )
869
+
870
+ # Auto-regenerate when feather slider changes
871
+ mask_feather.change(
872
+ fn=regenerate_mask_preview,
873
+ inputs=[mask_image, fg_points_state, bg_points_state, mask_feather],
874
+ outputs=[current_mask_state, mask_preview]
875
+ )
876
+
877
+ # ==========================================================================
878
+ # Metal Mask SAM 2 Event Handlers
879
+ # ==========================================================================
880
+
881
+ # Sync input image to metal mask editor when uploaded
882
+ input_img.change(
883
+ fn=on_image_upload_for_metal_mask,
884
+ inputs=[input_img],
885
+ outputs=[metal_fg_points_state, metal_bg_points_state, metal_current_segment_state,
886
+ metal_combined_mask_state, metal_mask_image]
887
+ )
888
+
889
+ # Handle clicks on metal mask image for point annotation
890
+ metal_mask_image.select(
891
+ fn=on_metal_mask_image_click,
892
+ inputs=[metal_mask_image, metal_fg_points_state, metal_bg_points_state,
893
+ metal_point_mode, metal_type_selector, metal_mask_feather],
894
+ outputs=[metal_fg_points_state, metal_bg_points_state,
895
+ metal_current_segment_state, metal_mask_preview]
896
+ )
897
+
898
+ # Auto-regenerate when metal feather slider changes
899
+ metal_mask_feather.change(
900
+ fn=regenerate_metal_mask_preview,
901
+ inputs=[metal_mask_image, metal_fg_points_state, metal_bg_points_state,
902
+ metal_type_selector, metal_mask_feather],
903
+ outputs=[metal_fg_points_state, metal_bg_points_state,
904
+ metal_current_segment_state, metal_mask_preview]
905
+ )
906
+
907
+ # Add current region to combined metal mask
908
+ def add_region_handler(current_segment, combined_mask, metal_type, image):
909
+ new_mask, status = add_metal_region_to_mask(current_segment, combined_mask, metal_type, None)
910
+ preview = create_metal_mask_preview(image, new_mask)
911
+ # Clear current segment points after adding
912
+ return [], [], None, new_mask, status, image, preview
913
+
914
+ add_metal_region_btn.click(
915
+ fn=add_region_handler,
916
+ inputs=[metal_current_segment_state, metal_combined_mask_state,
917
+ metal_type_selector, input_img],
918
+ outputs=[metal_fg_points_state, metal_bg_points_state, metal_current_segment_state,
919
+ metal_combined_mask_state, metal_status, metal_mask_preview, combined_metal_preview]
920
+ )
921
+
922
+ # Clear current segment (but keep combined mask)
923
+ clear_metal_segment_btn.click(
924
+ fn=clear_metal_mask_segment,
925
+ inputs=[input_img],
926
+ outputs=[metal_fg_points_state, metal_bg_points_state,
927
+ metal_current_segment_state, metal_mask_preview]
928
+ )
929
+
930
+ # Clear all metal masks
931
+ clear_all_metals_btn.click(
932
+ fn=clear_all_metal_masks,
933
+ inputs=[input_img],
934
+ outputs=[metal_fg_points_state, metal_bg_points_state, metal_current_segment_state,
935
+ metal_combined_mask_state, metal_mask_preview]
936
+ )
937
+
938
+ # Update combined preview when combined mask changes
939
+ def update_combined_preview(image, combined_mask):
940
+ if combined_mask is None:
941
+ return image
942
+ return create_metal_mask_preview(image, combined_mask)
943
+
944
+ metal_combined_mask_state.change(
945
+ fn=update_combined_preview,
946
+ inputs=[input_img, metal_combined_mask_state],
947
+ outputs=[combined_metal_preview]
948
+ )
949
+
950
+ # ==========================================================================
951
+ # Main Inference
952
+ # ==========================================================================
953
+
954
  run_button.click(
955
  inference,
956
  inputs=[
 
975
  emission_knee,
976
  emission_bloom,
977
  hardcoded_metal,
978
+ current_mask_state,
979
+ metal_combined_mask_state,
980
  ],
981
  outputs=[albedo_out, packed_out, normal_out, render_out]
982
  )
chord/minecraft_pbr.py CHANGED
@@ -74,6 +74,7 @@ def metalness_to_f0(
74
  metalness: torch.Tensor,
75
  threshold: float = 0.5,
76
  hardcoded_metal: str = "none",
 
77
  ) -> torch.Tensor:
78
  """
79
  Convert metalness to LabPBR F0/metal channel.
@@ -91,6 +92,10 @@ def metalness_to_f0(
91
  threshold: Threshold above which material is considered metal
92
  hardcoded_metal: Name of predefined metal type ("none", "custom", "iron", "gold", etc.)
93
  When not "none", metallic areas use this specific metal value instead of 255.
 
 
 
 
94
 
95
  Returns:
96
  F0 channel values in range [0, 1] (stored LINEAR, scaled to 0-255 on save)
@@ -109,12 +114,26 @@ def metalness_to_f0(
109
 
110
  # Blend based on metalness (hard threshold for cleaner results)
111
  is_metal = (metalness > threshold).float()
112
- return torch.lerp(
113
  torch.full_like(metalness, dielectric_f0),
114
  torch.full_like(metalness, metal_f0),
115
  is_metal
116
  )
117
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
118
 
119
  def convert_normal_to_directx(
120
  normal: torch.Tensor,
@@ -464,6 +483,7 @@ def create_specular_texture(
464
  sss: torch.Tensor = None,
465
  emission: torch.Tensor = None,
466
  hardcoded_metal: str = "none",
 
467
  sss_threshold: float = 0.01,
468
  ) -> torch.Tensor:
469
  """
@@ -486,6 +506,8 @@ def create_specular_texture(
486
  emission: Optional emission intensity map, range [0, 1] where 1 = max emission
487
  If provided, output is RGBA; if None, output is RGB
488
  hardcoded_metal: Predefined metal type for metallic areas ("none", "iron", "gold", etc.)
 
 
489
  sss_threshold: SSS intensity threshold for per-pixel priority (default 0.01)
490
  Pixels with SSS > threshold use SSS encoding, others use porosity
491
 
@@ -511,7 +533,7 @@ def create_specular_texture(
511
  smoothness = roughness_to_smoothness(roughness)
512
 
513
  # G: F0/Metal (stored LINEAR, uses hardcoded metal value for metallic areas if specified)
514
- f0 = metalness_to_f0(metalness, hardcoded_metal=hardcoded_metal)
515
 
516
  # B: Porosity (0-64) and/or SSS (65-255) - per-pixel blending
517
  # SSS takes priority where it exceeds threshold
@@ -654,6 +676,7 @@ def convert_to_labpbr(
654
  height_high_freq: float = 1.0,
655
  height_intensity: float = 1.0,
656
  height_invert: bool = True,
 
657
  compute_porosity: bool = False,
658
  normalize_porosity: bool = True,
659
  compute_sss: bool = False,
@@ -665,6 +688,7 @@ def convert_to_labpbr(
665
  emission_knee: float = 0.1,
666
  emission_bloom: int = 0,
667
  hardcoded_metal: str = "none",
 
668
  seamless: bool = False,
669
  ao_strength: float = 2.0,
670
  ao_blur: int = 5,
@@ -694,6 +718,8 @@ def convert_to_labpbr(
694
  height_intensity: Global height intensity/opacity (0.0 = flat, 1.0 = full height)
695
  height_invert: If True, invert height for correct POM direction (default True).
696
  This fixes the Frankot-Chellappa output so raised areas appear raised in POM.
 
 
697
  compute_porosity: If True, calculate porosity from AO, smoothness, and F0 when not provided
698
  normalize_porosity: If True, normalize porosity to full 0-1 range before LabPBR scaling
699
  compute_sss: If True, calculate SSS thickness from normal curvature and AO when not provided
@@ -706,6 +732,9 @@ def convert_to_labpbr(
706
  emission_bloom: Gaussian blur radius for emission bloom effect (0 = disabled)
707
  hardcoded_metal: Predefined metal type for specular G channel ("none", "iron", "gold", etc.)
708
  Uses metalness map as mask - metallic areas get this metal ID value.
 
 
 
709
  seamless: Whether the texture should tile seamlessly (for height derivation)
710
  ao_strength: AO intensity multiplier (higher = more contrast)
711
  ao_blur: Gaussian blur radius for AO smoothing
@@ -749,6 +778,7 @@ def convert_to_labpbr(
749
  height_intensity=height_intensity,
750
  height_min=0.25, # Minecraft POM: black = 25% block depth
751
  height_invert=height_invert,
 
752
  )
753
  if ao is None:
754
  ao = derived_ao
@@ -792,7 +822,8 @@ def convert_to_labpbr(
792
 
793
  # Create LabPBR textures
794
  specular_tex = create_specular_texture(
795
- roughness, metalness, porosity, sss, emission, hardcoded_metal=hardcoded_metal
 
796
  )
797
  normal_tex = create_normal_texture(
798
  normal, ao, height, flip_y=flip_normal_y, swap_xy=swap_normal_xy
 
74
  metalness: torch.Tensor,
75
  threshold: float = 0.5,
76
  hardcoded_metal: str = "none",
77
+ metal_mask: torch.Tensor = None,
78
  ) -> torch.Tensor:
79
  """
80
  Convert metalness to LabPBR F0/metal channel.
 
92
  threshold: Threshold above which material is considered metal
93
  hardcoded_metal: Name of predefined metal type ("none", "custom", "iron", "gold", etc.)
94
  When not "none", metallic areas use this specific metal value instead of 255.
95
+ metal_mask: Optional per-pixel metal type mask, shape matching metalness.
96
+ Values are LabPBR metal IDs (230-255) normalized to [0, 1].
97
+ Where mask > 0, uses the mask value directly as F0.
98
+ Where mask = 0, falls back to default metalness-based behavior.
99
 
100
  Returns:
101
  F0 channel values in range [0, 1] (stored LINEAR, scaled to 0-255 on save)
 
114
 
115
  # Blend based on metalness (hard threshold for cleaner results)
116
  is_metal = (metalness > threshold).float()
117
+ f0 = torch.lerp(
118
  torch.full_like(metalness, dielectric_f0),
119
  torch.full_like(metalness, metal_f0),
120
  is_metal
121
  )
122
 
123
+ # Apply per-pixel metal mask if provided
124
+ # Metal mask contains normalized metal IDs (e.g., 230/255 for iron)
125
+ # Where mask > 0, override with mask value
126
+ if metal_mask is not None:
127
+ # Ensure mask has same shape as f0
128
+ if metal_mask.shape != f0.shape:
129
+ # Try to broadcast or resize
130
+ if metal_mask.dim() < f0.dim():
131
+ metal_mask = metal_mask.unsqueeze(0)
132
+ mask_active = (metal_mask > 0).float()
133
+ f0 = torch.where(mask_active > 0.5, metal_mask, f0)
134
+
135
+ return f0
136
+
137
 
138
  def convert_normal_to_directx(
139
  normal: torch.Tensor,
 
483
  sss: torch.Tensor = None,
484
  emission: torch.Tensor = None,
485
  hardcoded_metal: str = "none",
486
+ metal_mask: torch.Tensor = None,
487
  sss_threshold: float = 0.01,
488
  ) -> torch.Tensor:
489
  """
 
506
  emission: Optional emission intensity map, range [0, 1] where 1 = max emission
507
  If provided, output is RGBA; if None, output is RGB
508
  hardcoded_metal: Predefined metal type for metallic areas ("none", "iron", "gold", etc.)
509
+ metal_mask: Optional per-pixel metal type mask with normalized LabPBR metal IDs.
510
+ Where mask > 0, uses mask value as F0 (overrides hardcoded_metal).
511
  sss_threshold: SSS intensity threshold for per-pixel priority (default 0.01)
512
  Pixels with SSS > threshold use SSS encoding, others use porosity
513
 
 
533
  smoothness = roughness_to_smoothness(roughness)
534
 
535
  # G: F0/Metal (stored LINEAR, uses hardcoded metal value for metallic areas if specified)
536
+ f0 = metalness_to_f0(metalness, hardcoded_metal=hardcoded_metal, metal_mask=metal_mask)
537
 
538
  # B: Porosity (0-64) and/or SSS (65-255) - per-pixel blending
539
  # SSS takes priority where it exceeds threshold
 
676
  height_high_freq: float = 1.0,
677
  height_intensity: float = 1.0,
678
  height_invert: bool = True,
679
+ height_mask: torch.Tensor = None,
680
  compute_porosity: bool = False,
681
  normalize_porosity: bool = True,
682
  compute_sss: bool = False,
 
688
  emission_knee: float = 0.1,
689
  emission_bloom: int = 0,
690
  hardcoded_metal: str = "none",
691
+ metal_mask: torch.Tensor = None,
692
  seamless: bool = False,
693
  ao_strength: float = 2.0,
694
  ao_blur: int = 5,
 
718
  height_intensity: Global height intensity/opacity (0.0 = flat, 1.0 = full height)
719
  height_invert: If True, invert height for correct POM direction (default True).
720
  This fixes the Frankot-Chellappa output so raised areas appear raised in POM.
721
+ height_mask: Optional mask tensor where 1=suppress height (flatten), 0=keep height.
722
+ Used with SAM segmentation for POM masking. Masked regions become flat (min_height).
723
  compute_porosity: If True, calculate porosity from AO, smoothness, and F0 when not provided
724
  normalize_porosity: If True, normalize porosity to full 0-1 range before LabPBR scaling
725
  compute_sss: If True, calculate SSS thickness from normal curvature and AO when not provided
 
732
  emission_bloom: Gaussian blur radius for emission bloom effect (0 = disabled)
733
  hardcoded_metal: Predefined metal type for specular G channel ("none", "iron", "gold", etc.)
734
  Uses metalness map as mask - metallic areas get this metal ID value.
735
+ metal_mask: Optional per-pixel metal type mask with normalized LabPBR metal IDs (0-1 scale).
736
+ Created via SAM segmentation. Where mask > 0, overrides hardcoded_metal with per-pixel values.
737
+ Values should be LabPBR metal IDs divided by 255 (e.g., 230/255 for iron).
738
  seamless: Whether the texture should tile seamlessly (for height derivation)
739
  ao_strength: AO intensity multiplier (higher = more contrast)
740
  ao_blur: Gaussian blur radius for AO smoothing
 
778
  height_intensity=height_intensity,
779
  height_min=0.25, # Minecraft POM: black = 25% block depth
780
  height_invert=height_invert,
781
+ height_mask=height_mask,
782
  )
783
  if ao is None:
784
  ao = derived_ao
 
822
 
823
  # Create LabPBR textures
824
  specular_tex = create_specular_texture(
825
+ roughness, metalness, porosity, sss, emission,
826
+ hardcoded_metal=hardcoded_metal, metal_mask=metal_mask
827
  )
828
  normal_tex = create_normal_texture(
829
  normal, ao, height, flip_y=flip_normal_y, swap_xy=swap_normal_xy
chord/normal_utils.py CHANGED
@@ -161,6 +161,7 @@ def normal_to_height(
161
  intensity: float = 1.0,
162
  min_height: float = 0.25,
163
  invert: bool = True,
 
164
  ) -> torch.Tensor:
165
  """
166
  Convert normal map to height map using Frankot-Chellappa algorithm.
@@ -177,6 +178,8 @@ def normal_to_height(
177
  invert: If True, invert height so raised areas in normal map appear raised
178
  in POM (default True). The Frankot-Chellappa integration can produce
179
  inverted heights depending on gradient sign conventions.
 
 
180
 
181
  Returns:
182
  Height map (B, 1, H, W) or (1, H, W), range [min_height, 1.0]
@@ -216,6 +219,30 @@ def normal_to_height(
216
  midpoint = (min_height + 1.0) / 2.0
217
  height = midpoint + (height - midpoint) * intensity
218
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
219
  if squeeze:
220
  height = height.squeeze(0)
221
 
@@ -336,6 +363,7 @@ def derive_ao_and_height(
336
  height_intensity: float = 1.0,
337
  height_min: float = 0.25,
338
  height_invert: bool = True,
 
339
  ) -> tuple[torch.Tensor, torch.Tensor]:
340
  """
341
  Derive both AO and height from a normal map.
@@ -351,6 +379,8 @@ def derive_ao_and_height(
351
  height_intensity: Global height intensity/opacity (0.0 = flat, 1.0 = full)
352
  height_min: Minimum height value (default 0.25 for Minecraft POM)
353
  height_invert: If True, invert height for correct POM direction (default True)
 
 
354
 
355
  Returns:
356
  ao: Ambient occlusion (1.0 = no occlusion)
@@ -366,6 +396,7 @@ def derive_ao_and_height(
366
  intensity=height_intensity,
367
  min_height=height_min,
368
  invert=height_invert,
 
369
  )
370
 
371
  return ao, height
 
161
  intensity: float = 1.0,
162
  min_height: float = 0.25,
163
  invert: bool = True,
164
+ height_mask: torch.Tensor = None,
165
  ) -> torch.Tensor:
166
  """
167
  Convert normal map to height map using Frankot-Chellappa algorithm.
 
178
  invert: If True, invert height so raised areas in normal map appear raised
179
  in POM (default True). The Frankot-Chellappa integration can produce
180
  inverted heights depending on gradient sign conventions.
181
+ height_mask: Optional mask tensor where 1=suppress height (flatten),
182
+ 0=keep height. Used with SAM segmentation for POM masking.
183
 
184
  Returns:
185
  Height map (B, 1, H, W) or (1, H, W), range [min_height, 1.0]
 
219
  midpoint = (min_height + 1.0) / 2.0
220
  height = midpoint + (height - midpoint) * intensity
221
 
222
+ # Apply height mask (mask=1 suppresses height, mask=0 keeps height)
223
+ if height_mask is not None:
224
+ # Ensure mask has correct shape (B, 1, H, W)
225
+ if height_mask.dim() == 2:
226
+ height_mask = height_mask.unsqueeze(0).unsqueeze(0)
227
+ elif height_mask.dim() == 3:
228
+ height_mask = height_mask.unsqueeze(0)
229
+
230
+ # Resize mask to match height dimensions if needed
231
+ if height_mask.shape[-2:] != height.shape[-2:]:
232
+ height_mask = F.interpolate(
233
+ height_mask.float(),
234
+ size=height.shape[-2:],
235
+ mode='bilinear',
236
+ align_corners=False
237
+ )
238
+
239
+ # Move mask to same device/dtype as height
240
+ height_mask = height_mask.to(device=height.device, dtype=height.dtype)
241
+
242
+ # Blend: masked areas (mask=1) go to min_height (flat surface)
243
+ # Unmasked areas (mask=0) keep their derived height
244
+ height = height * (1.0 - height_mask) + min_height * height_mask
245
+
246
  if squeeze:
247
  height = height.squeeze(0)
248
 
 
363
  height_intensity: float = 1.0,
364
  height_min: float = 0.25,
365
  height_invert: bool = True,
366
+ height_mask: torch.Tensor = None,
367
  ) -> tuple[torch.Tensor, torch.Tensor]:
368
  """
369
  Derive both AO and height from a normal map.
 
379
  height_intensity: Global height intensity/opacity (0.0 = flat, 1.0 = full)
380
  height_min: Minimum height value (default 0.25 for Minecraft POM)
381
  height_invert: If True, invert height for correct POM direction (default True)
382
+ height_mask: Optional mask tensor where 1=suppress height (flatten),
383
+ 0=keep height. Used with SAM segmentation for POM masking.
384
 
385
  Returns:
386
  ao: Ambient occlusion (1.0 = no occlusion)
 
396
  intensity=height_intensity,
397
  min_height=height_min,
398
  invert=height_invert,
399
+ height_mask=height_mask,
400
  )
401
 
402
  return ao, height
chord/sam_segmenter.py ADDED
@@ -0,0 +1,207 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ SAM 2 Segmentation Wrapper for POM Height Masking.
3
+
4
+ Provides interactive point-prompt segmentation using SAM 2.1 Hiera Small.
5
+ Users click on images to segment regions for height mask creation.
6
+ """
7
+
8
+ import torch
9
+ import numpy as np
10
+ from PIL import Image, ImageDraw
11
+ from typing import Optional, Tuple, List
12
+
13
+ # Global singleton for lazy loading
14
+ _SAM2_PREDICTOR = None
15
+ _SAM2_MODEL_CFG = None
16
+
17
+
18
+ def _get_sam2_predictor():
19
+ """Lazy load SAM 2 predictor on first use."""
20
+ global _SAM2_PREDICTOR, _SAM2_MODEL_CFG
21
+
22
+ if _SAM2_PREDICTOR is None:
23
+ print("Loading SAM 2 model...")
24
+
25
+ from sam2.build_sam import build_sam2_hf
26
+ from sam2.sam2_image_predictor import SAM2ImagePredictor
27
+
28
+ device = torch.device("cuda" if torch.cuda.is_available() else "cpu")
29
+
30
+ # Use build_sam2_hf which handles HuggingFace model loading directly
31
+ sam2_model = build_sam2_hf(
32
+ model_id="facebook/sam2.1-hiera-small",
33
+ device=device
34
+ )
35
+ _SAM2_PREDICTOR = SAM2ImagePredictor(sam2_model)
36
+ _SAM2_MODEL_CFG = "sam2.1_hiera_s"
37
+
38
+ print(f"SAM 2 loaded on {device}")
39
+
40
+ return _SAM2_PREDICTOR
41
+
42
+
43
+ class SAM2Segmenter:
44
+ """SAM 2 wrapper for interactive point-prompt segmentation."""
45
+
46
+ def __init__(self):
47
+ self.predictor = None
48
+ self.current_image = None
49
+ self._image_set = False
50
+
51
+ def set_image(self, image: Image.Image) -> None:
52
+ """Set the image for segmentation. Must be called before predict."""
53
+ if self.predictor is None:
54
+ self.predictor = _get_sam2_predictor()
55
+
56
+ # Convert PIL to numpy array (RGB)
57
+ image_np = np.array(image.convert("RGB"))
58
+
59
+ with torch.inference_mode():
60
+ self.predictor.set_image(image_np)
61
+
62
+ self.current_image = image
63
+ self._image_set = True
64
+
65
+ def predict_mask(
66
+ self,
67
+ fg_points: List[Tuple[int, int]],
68
+ bg_points: Optional[List[Tuple[int, int]]] = None,
69
+ multimask_output: bool = True,
70
+ ) -> Tuple[np.ndarray, float]:
71
+ """
72
+ Predict segmentation mask from point prompts.
73
+
74
+ Args:
75
+ fg_points: List of (x, y) foreground points (areas to include)
76
+ bg_points: List of (x, y) background points (areas to exclude)
77
+ multimask_output: If True, return best of 3 masks
78
+
79
+ Returns:
80
+ mask: Binary mask (H, W) as numpy array, 1=selected region
81
+ score: IoU prediction score
82
+ """
83
+ if not self._image_set:
84
+ raise RuntimeError("Must call set_image() before predict_mask()")
85
+
86
+ if not fg_points:
87
+ raise ValueError("At least one foreground point is required")
88
+
89
+ # Build point arrays
90
+ all_points = []
91
+ all_labels = []
92
+
93
+ for x, y in fg_points:
94
+ all_points.append([x, y])
95
+ all_labels.append(1) # Foreground
96
+
97
+ if bg_points:
98
+ for x, y in bg_points:
99
+ all_points.append([x, y])
100
+ all_labels.append(0) # Background
101
+
102
+ point_coords = np.array(all_points)
103
+ point_labels = np.array(all_labels)
104
+
105
+ with torch.inference_mode():
106
+ # Use autocast if on CUDA
107
+ device = self.predictor.device
108
+ if device.type == "cuda":
109
+ with torch.autocast("cuda", dtype=torch.bfloat16):
110
+ masks, scores, _ = self.predictor.predict(
111
+ point_coords=point_coords,
112
+ point_labels=point_labels,
113
+ multimask_output=multimask_output,
114
+ )
115
+ else:
116
+ masks, scores, _ = self.predictor.predict(
117
+ point_coords=point_coords,
118
+ point_labels=point_labels,
119
+ multimask_output=multimask_output,
120
+ )
121
+
122
+ # Return best mask (highest IoU score)
123
+ best_idx = np.argmax(scores)
124
+ return masks[best_idx].astype(np.float32), float(scores[best_idx])
125
+
126
+ def clear(self) -> None:
127
+ """Reset the segmenter state."""
128
+ self._image_set = False
129
+ self.current_image = None
130
+
131
+
132
+ def create_mask_overlay(
133
+ image: Image.Image,
134
+ mask: np.ndarray,
135
+ color: Tuple[int, int, int] = (255, 100, 100),
136
+ alpha: float = 0.5,
137
+ ) -> Image.Image:
138
+ """
139
+ Create visualization overlay of mask on image.
140
+
141
+ Args:
142
+ image: Original PIL image
143
+ mask: Binary mask (H, W) with values 0-1
144
+ color: RGB color for mask overlay
145
+ alpha: Transparency of overlay (0-1)
146
+
147
+ Returns:
148
+ PIL image with mask overlay
149
+ """
150
+ image_np = np.array(image.convert("RGB")).astype(np.float32)
151
+
152
+ # Expand mask to 3 channels
153
+ mask_3ch = np.stack([mask, mask, mask], axis=-1)
154
+
155
+ # Create colored overlay
156
+ color_overlay = np.array(color, dtype=np.float32)
157
+
158
+ # Blend: original * (1 - mask*alpha) + color * (mask*alpha)
159
+ overlay = image_np * (1 - mask_3ch * alpha) + color_overlay * (mask_3ch * alpha)
160
+ overlay = np.clip(overlay, 0, 255).astype(np.uint8)
161
+
162
+ return Image.fromarray(overlay)
163
+
164
+
165
+ def draw_points_on_image(
166
+ image: Image.Image,
167
+ fg_points: List[Tuple[int, int]],
168
+ bg_points: Optional[List[Tuple[int, int]]] = None,
169
+ point_radius: int = 6,
170
+ ) -> Image.Image:
171
+ """
172
+ Draw foreground (green) and background (red) points on image.
173
+
174
+ Args:
175
+ image: PIL image to draw on
176
+ fg_points: Foreground points (green)
177
+ bg_points: Background points (red)
178
+ point_radius: Radius of point circles
179
+
180
+ Returns:
181
+ PIL image with points drawn
182
+ """
183
+ draw_img = image.copy()
184
+ draw = ImageDraw.Draw(draw_img)
185
+
186
+ r = point_radius
187
+
188
+ # Draw foreground points (green)
189
+ for x, y in fg_points:
190
+ draw.ellipse(
191
+ [x - r, y - r, x + r, y + r],
192
+ fill=(0, 255, 0),
193
+ outline=(0, 180, 0),
194
+ width=2
195
+ )
196
+
197
+ # Draw background points (red)
198
+ if bg_points:
199
+ for x, y in bg_points:
200
+ draw.ellipse(
201
+ [x - r, y - r, x + r, y + r],
202
+ fill=(255, 0, 0),
203
+ outline=(180, 0, 0),
204
+ width=2
205
+ )
206
+
207
+ return draw_img
requirements.txt CHANGED
@@ -10,4 +10,5 @@ omegaconf
10
  imageio
11
  gradio
12
  spaces
13
- python-dotenv
 
 
10
  imageio
11
  gradio
12
  spaces
13
+ python-dotenv
14
+ sam2