import unittest import torch from inpaint_cropandstitch import ( InpaintCropImproved, InpaintStitchImproved, _sanitize_image_tensor, _sanitize_mask_tensor, ) class TestInputShielding(unittest.TestCase): def setUp(self): self.crop_node = InpaintCropImproved() self.stitch_node = InpaintStitchImproved() # ========================================================================= # 1. Tests for _sanitize_image_tensor # ========================================================================= def test_sanitize_image_non_tensor_raises(self): with self.assertRaises(TypeError): _sanitize_image_tensor("not_a_tensor") with self.assertRaises(TypeError): _sanitize_image_tensor([1.0, 2.0, 3.0]) def test_sanitize_image_2d(self): # [H, W] -> [1, H, W, 3] (allow_grayscale=False default) t = torch.rand(40, 50, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 40, 50, 3)) # With allow_grayscale=True -> [1, 40, 50, 1] sanitized_gray = _sanitize_image_tensor(t, allow_grayscale=True) self.assertEqual(sanitized_gray.shape, (1, 40, 50, 1)) def test_sanitize_image_3d_unbatched_hwc(self): # [H, W, 3] -> [1, H, W, 3] t = torch.rand(30, 40, 3, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40, 3)) def test_sanitize_image_3d_channels_first_chw(self): # [3, H, W] -> [1, H, W, 3] t = torch.rand(3, 30, 40, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40, 3)) def test_sanitize_image_4d_channels_first_bchw(self): # [2, 3, 30, 40] -> [2, 30, 40, 3] t = torch.rand(2, 3, 30, 40, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (2, 30, 40, 3)) def test_sanitize_image_5d_singleton(self): # [1, 1, 30, 40, 3] -> [1, 30, 40, 3] t = torch.rand(1, 1, 30, 40, 3, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40, 3)) def test_sanitize_image_2channel_to_rgba(self): # [1, 30, 40, 2] (grayscale + alpha) -> [1, 30, 40, 4] t = torch.rand(1, 30, 40, 2, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40, 4)) def test_sanitize_image_6channel_clamped(self): # >4 channels -> clamped to first 4 channels t = torch.rand(1, 30, 40, 6, dtype=torch.float32) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40, 4)) def test_sanitize_image_uint8_conversion(self): # uint8 0..255 -> float32 0..1 t = (torch.rand(1, 20, 20, 3) * 255).to(torch.uint8) sanitized = _sanitize_image_tensor(t) self.assertEqual(sanitized.dtype, torch.float32) self.assertLessEqual(sanitized.max().item(), 1.0) self.assertGreaterEqual(sanitized.min().item(), 0.0) def test_sanitize_image_nans_and_infs(self): t = torch.tensor([[[[float("nan"), float("inf"), float("-inf"), 0.5]]]]) sanitized = _sanitize_image_tensor(t) self.assertFalse(torch.isnan(sanitized).any()) self.assertFalse(torch.isinf(sanitized).any()) # ========================================================================= # 2. Tests for _sanitize_mask_tensor # ========================================================================= def test_sanitize_mask_none_and_empty(self): self.assertIsNone(_sanitize_mask_tensor(None)) self.assertIsNone(_sanitize_mask_tensor(torch.tensor([]))) def test_sanitize_mask_non_tensor_raises(self): with self.assertRaises(TypeError): _sanitize_mask_tensor("not_a_mask") def test_sanitize_mask_2d(self): # [H, W] -> [1, H, W] t = torch.rand(30, 40) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40)) def test_sanitize_mask_3d_standard(self): # [B, H, W] stays [B, H, W] t = torch.rand(2, 30, 40) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (2, 30, 40)) def test_sanitize_mask_3d_trailing_singleton(self): # [30, 40, 1] unbatched with channel -> [1, 30, 40] t = torch.rand(30, 40, 1) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40)) def test_sanitize_mask_4d_rgb_image_input(self): # RGB IMAGE [B, H, W, 3] wired to mask input -> converted to [B, H, W] luminance t = torch.rand(2, 30, 40, 3) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (2, 30, 40)) def test_sanitize_mask_4d_rgba_image_input(self): # RGBA IMAGE [B, H, W, 4] wired to mask input -> converted to [B, H, W] luminance t = torch.rand(1, 30, 40, 4) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (1, 30, 40)) def test_sanitize_mask_4d_channels_first_image_input(self): # Channels-first [B, 3, H, W] -> converted to [B, H, W] t = torch.rand(2, 3, 30, 40) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.shape, (2, 30, 40)) def test_sanitize_mask_uint8_and_clamping(self): t = (torch.rand(1, 20, 20) * 255).to(torch.uint8) sanitized = _sanitize_mask_tensor(t) self.assertEqual(sanitized.dtype, torch.float32) self.assertLessEqual(sanitized.max().item(), 1.0) self.assertGreaterEqual(sanitized.min().item(), 0.0) def test_sanitize_mask_float_slightly_above_one_is_not_rescaled(self): # Issue #182: blur/resize float noise can push a 0..1 mask slightly above 1.0. # That must not be mistaken for a 0..255 mask. for peak in [1.0000006, 1.05, 1.2]: with self.subTest(peak=peak): t = torch.zeros(1, 20, 20) t[0, 5:15, 5:15] = 0.5 t[0, 10, 10] = peak sanitized = _sanitize_mask_tensor(t) self.assertAlmostEqual(sanitized[0, 6, 6].item(), 0.5, places=6) self.assertEqual(sanitized[0, 10, 10].item(), 1.0) def test_sanitize_mask_0_255_still_normalized(self): float_255 = torch.zeros(1, 20, 20) float_255[0, 5:15, 5:15] = 127.5 float_255[0, 10, 10] = 255.0 sanitized = _sanitize_mask_tensor(float_255) self.assertAlmostEqual(sanitized[0, 6, 6].item(), 0.5, places=6) self.assertAlmostEqual(sanitized[0, 10, 10].item(), 1.0, places=6) uint8_255 = torch.zeros(1, 20, 20, dtype=torch.uint8) uint8_255[0, 5:15, 5:15] = 255 self.assertAlmostEqual(_sanitize_mask_tensor(uint8_255)[0, 6, 6].item(), 1.0, places=6) # Integer 0/1 masks keep their meaning uint8_01 = torch.zeros(1, 20, 20, dtype=torch.uint8) uint8_01[0, 5:15, 5:15] = 1 self.assertEqual(_sanitize_mask_tensor(uint8_01)[0, 6, 6].item(), 1.0) def test_sanitize_mask_nans_and_infs(self): t = torch.tensor([[float("nan"), float("inf"), float("-inf"), 0.5]]) sanitized = _sanitize_mask_tensor(t) self.assertFalse(torch.isnan(sanitized).any()) self.assertFalse(torch.isinf(sanitized).any()) self.assertLessEqual(sanitized.max().item(), 1.0) self.assertGreaterEqual(sanitized.min().item(), 0.0) # ========================================================================= # 3. Integration Tests for InpaintCropImproved Shielding # ========================================================================= def _default_crop_args(self, image, mask=None, **kwargs): args = { "image": image, "mask": mask, "optional_context_mask": None, "downscale_algorithm": "bilinear", "upscale_algorithm": "bicubic", "preresize": False, "preresize_mode": "no preresize", "preresize_min_width": 256, "preresize_min_height": 256, "preresize_max_width": 1024, "preresize_max_height": 1024, "extend_for_outpainting": False, "extend_up_factor": 1.0, "extend_down_factor": 1.0, "extend_left_factor": 1.0, "extend_right_factor": 1.0, "mask_hipass_filter": 0.0, "mask_fill_holes": False, "mask_expand_pixels": 0, "mask_invert": False, "mask_blend_pixels": 0, "context_from_mask_extend_factor": 1.2, "output_resize_to_target_size": False, "output_target_width": 256, "output_target_height": 256, "output_padding": 8, "device_mode": "cpu (compatible)", } args.update(kwargs) return args def test_crop_with_rgb_image_as_mask(self): # User mistakenly wired IMAGE [1, 64, 64, 3] to MASK input image = torch.rand(1, 64, 64, 3, dtype=torch.float32) image_as_mask = torch.zeros(1, 64, 64, 3, dtype=torch.float32) image_as_mask[0, 20:40, 20:40, :] = 1.0 # White square args = self._default_crop_args(image, mask=image_as_mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] self.assertEqual(crop_im.ndim, 4) self.assertEqual(crop_im.shape[-1], 3) self.assertEqual(crop_m.ndim, 3) def test_crop_with_rgba_image_as_mask(self): # User mistakenly wired RGBA IMAGE [1, 64, 64, 4] to MASK input image = torch.rand(1, 64, 64, 3, dtype=torch.float32) rgba_as_mask = torch.zeros(1, 64, 64, 4, dtype=torch.float32) rgba_as_mask[0, 20:40, 20:40, :] = 1.0 args = self._default_crop_args(image, mask=rgba_as_mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] self.assertEqual(crop_im.ndim, 4) self.assertEqual(crop_m.ndim, 3) def test_crop_with_unbatched_inputs(self): # 3D unbatched image [64, 64, 3] and 2D unbatched mask [64, 64] image = torch.rand(64, 64, 3, dtype=torch.float32) mask = torch.zeros(64, 64, dtype=torch.float32) mask[20:40, 20:40] = 1.0 args = self._default_crop_args(image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] self.assertEqual(crop_im.shape[0], 1) self.assertEqual(crop_m.shape[0], 1) def test_crop_mismatched_batch_harmonization(self): # Image batch = 1, Mask batch = 3 image = torch.rand(1, 64, 64, 3, dtype=torch.float32) mask = torch.zeros(3, 64, 64, dtype=torch.float32) mask[:, 20:40, 20:40] = 1.0 args = self._default_crop_args(image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] self.assertEqual(crop_im.shape[0], 3) self.assertEqual(crop_m.shape[0], 3) self.assertEqual(len(stitcher["canvas_image"]), 3) def test_crop_with_extreme_and_negative_widget_values(self): # Negative factors, high blend pixels, negative padding should not crash image = torch.rand(1, 64, 64, 3, dtype=torch.float32) mask = torch.zeros(1, 64, 64, dtype=torch.float32) mask[0, 20:40, 20:40] = 1.0 args = self._default_crop_args( image, mask=mask, extend_up_factor=-0.5, extend_down_factor=-1.0, mask_blend_pixels=-5, mask_expand_pixels=-2, context_from_mask_extend_factor=0.5, # should be bounded to >= 1.0 output_padding=-4, mask_hipass_filter=2.5, # should be clamped to 1.0 ) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] self.assertIsNotNone(crop_im) self.assertIsNotNone(stitcher) # ========================================================================= # 4. Integration Tests for InpaintStitchImproved Shielding (Issue #179 & Edge Cases) # ========================================================================= def test_stitch_qwen_rgba_inpaint_into_rgb_canvas_issue_179(self): # Issue #179 scenario: # Canvas was cropped from standard RGB [1, 100, 100, 3] # Inpaint node (e.g. Qwen 2.1 / WanVAE) outputs RGBA [1, H, W, 4] orig_image = torch.rand(1, 100, 100, 3, dtype=torch.float32) mask = torch.zeros(1, 100, 100, dtype=torch.float32) mask[0, 30:60, 30:60] = 1.0 args = self._default_crop_args(orig_image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] # Simulate Qwen 2.1 returning RGBA with 4 channels for a regular (opaque) edit h, w = crop_im.shape[1], crop_im.shape[2] rgba_inpaint = torch.rand(1, h, w, 4, dtype=torch.float32) rgba_inpaint[..., 3] = 1.0 # Execute stitch (stitched,) = self.stitch_node.inpaint_stitch(stitcher, rgba_inpaint) self.assertEqual(stitched.shape, (1, 100, 100, 3)) self.assertFalse(torch.isnan(stitched).any()) def test_stitch_rgb_inpaint_into_rgba_canvas(self): # Canvas was RGBA [1, 100, 100, 4] # Inpainted image is RGB [1, H, W, 3] orig_image = torch.rand(1, 100, 100, 4, dtype=torch.float32) mask = torch.zeros(1, 100, 100, dtype=torch.float32) mask[0, 30:60, 30:60] = 1.0 args = self._default_crop_args(orig_image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] # Standard 3-channel RGB inpaint h, w = crop_im.shape[1], crop_im.shape[2] rgb_inpaint = torch.rand(1, h, w, 3, dtype=torch.float32) (stitched,) = self.stitch_node.inpaint_stitch(stitcher, rgb_inpaint) # Stitched preserves the canvas RGBA channels self.assertEqual(stitched.shape, (1, 100, 100, 4)) self.assertFalse(torch.isnan(stitched).any()) def test_stitch_channels_first_inpainted_image(self): # Upstream node outputs [B, C, H, W] instead of [B, H, W, C] orig_image = torch.rand(1, 80, 80, 3, dtype=torch.float32) mask = torch.zeros(1, 80, 80, dtype=torch.float32) mask[0, 20:50, 20:50] = 1.0 args = self._default_crop_args(orig_image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] h, w = crop_im.shape[1], crop_im.shape[2] # Inpainted image passed as [1, 3, H, W] bchw_inpaint = torch.rand(1, 3, h, w, dtype=torch.float32) (stitched,) = self.stitch_node.inpaint_stitch(stitcher, bchw_inpaint) self.assertEqual(stitched.shape, (1, 80, 80, 3)) def test_stitch_unbatched_inpainted_image(self): # Upstream node outputs 3D unbatched [H, W, C] orig_image = torch.rand(1, 80, 80, 3, dtype=torch.float32) mask = torch.zeros(1, 80, 80, dtype=torch.float32) mask[0, 20:50, 20:50] = 1.0 args = self._default_crop_args(orig_image, mask=mask) stitcher, crop_im, crop_m = self.crop_node.inpaint_crop(**args)[:3] h, w = crop_im.shape[1], crop_im.shape[2] hwc_inpaint = torch.rand(h, w, 3, dtype=torch.float32) (stitched,) = self.stitch_node.inpaint_stitch(stitcher, hwc_inpaint) self.assertEqual(stitched.shape, (1, 80, 80, 3)) def test_stitch_invalid_stitcher_types_raise(self): inpaint = torch.rand(1, 30, 30, 3) with self.assertRaises(TypeError): self.stitch_node.inpaint_stitch("not_a_dict", inpaint) with self.assertRaises(TypeError): self.stitch_node.inpaint_stitch(None, inpaint) with self.assertRaises(ValueError): self.stitch_node.inpaint_stitch({}, inpaint) with self.assertRaises(ValueError): self.stitch_node.inpaint_stitch({"cropped_to_canvas_x": []}, inpaint) if __name__ == "__main__": unittest.main()