marigold model/pipeline review
Commit tested: 0f1abc4ae8b0eb2a3b40e82a310507281144c423
Review performed against the repository review rules.
Issue 1: NumPy HWC images validate returned latents against the wrong shape
Affected code:
| ifisinstance(img, np.ndarray) ortorch.is_tensor(img): |
| ifimg.ndimnotin (2, 3, 4): |
| raiseValueError(f"`image[{i}]` has unsupported dimensions or shape: {img.shape}.") |
| H_i, W_i=img.shape[-2:] |
| N_i=1 |
| ifimg.ndim==4: |
| N_i=img.shape[0] |
| elifisinstance(img, Image.Image): |
| W_i, H_i=img.size |
| N_i=1 |
| else: |
| raiseValueError(f"Unsupported `image[{i}]` type: {type(img)}.") |
| ifWisNone: |
| W, H=W_i, H_i |
| elif (W, H) != (W_i, H_i): |
| raiseValueError( |
| f"Input `image[{i}]` has incompatible dimensions {(W_i, H_i)} with the previous images {(W, H)}" |
| ) |
| num_images+=N_i |
| |
| # latents checks |
| iflatentsisnotNone: |
| ifnottorch.is_tensor(latents): |
| raiseValueError("`latents` must be a torch.Tensor.") |
| iflatents.dim() !=4: |
| raiseValueError(f"`latents` has unsupported dimensions or shape: {latents.shape}.") |
| |
| ifprocessing_resolution>0: |
| max_orig=max(H, W) |
| new_H=H*processing_resolution//max_orig |
| new_W=W*processing_resolution//max_orig |
| ifnew_H==0ornew_W==0: |
| raiseValueError(f"Extreme aspect ratio of the input image: [{W} x {H}]") |
| W, H=new_W, new_H |
| w= (W+self.vae_scale_factor-1) //self.vae_scale_factor |
| h= (H+self.vae_scale_factor-1) //self.vae_scale_factor |
| shape_expected= (num_images*ensemble_size, self.vae.config.latent_channels, h, w) |
| |
| iflatents.shape!=shape_expected: |
| raiseValueError(f"`latents` has unexpected shape={latents.shape} expected={shape_expected}.") |
| ifisinstance(img, np.ndarray) ortorch.is_tensor(img): |
| ifimg.ndimnotin (2, 3, 4): |
| raiseValueError(f"`image[{i}]` has unsupported dimensions or shape: {img.shape}.") |
| H_i, W_i=img.shape[-2:] |
| N_i=1 |
| ifimg.ndim==4: |
| N_i=img.shape[0] |
| elifisinstance(img, Image.Image): |
| W_i, H_i=img.size |
| N_i=1 |
| else: |
| raiseValueError(f"Unsupported `image[{i}]` type: {type(img)}.") |
| ifWisNone: |
| W, H=W_i, H_i |
| elif (W, H) != (W_i, H_i): |
| raiseValueError( |
| f"Input `image[{i}]` has incompatible dimensions {(W_i, H_i)} with the previous images {(W, H)}" |
| ) |
| num_images+=N_i |
| |
| # latents checks |
| iflatentsisnotNone: |
| ifnottorch.is_tensor(latents): |
| raiseValueError("`latents` must be a torch.Tensor.") |
| iflatents.dim() !=4: |
| raiseValueError(f"`latents` has unsupported dimensions or shape: {latents.shape}.") |
| |
| ifprocessing_resolution>0: |
| max_orig=max(H, W) |
| new_H=H*processing_resolution//max_orig |
| new_W=W*processing_resolution//max_orig |
| ifnew_H==0ornew_W==0: |
| raiseValueError(f"Extreme aspect ratio of the input image: [{W} x {H}]") |
| W, H=new_W, new_H |
| w= (W+self.vae_scale_factor-1) //self.vae_scale_factor |
| h= (H+self.vae_scale_factor-1) //self.vae_scale_factor |
| shape_expected= (num_images*ensemble_size, self.unet.config.out_channels, h, w) |
| |
| iflatents.shape!=shape_expected: |
| raiseValueError(f"`latents` has unexpected shape={latents.shape} expected={shape_expected}.") |
| ifisinstance(img, np.ndarray) ortorch.is_tensor(img): |
| ifimg.ndimnotin (2, 3, 4): |
| raiseValueError(f"`image[{i}]` has unsupported dimensions or shape: {img.shape}.") |
| H_i, W_i=img.shape[-2:] |
| N_i=1 |
| ifimg.ndim==4: |
| N_i=img.shape[0] |
| elifisinstance(img, Image.Image): |
| W_i, H_i=img.size |
| N_i=1 |
| else: |
| raiseValueError(f"Unsupported `image[{i}]` type: {type(img)}.") |
| ifWisNone: |
| W, H=W_i, H_i |
| elif (W, H) != (W_i, H_i): |
| raiseValueError( |
| f"Input `image[{i}]` has incompatible dimensions {(W_i, H_i)} with the previous images {(W, H)}" |
| ) |
| num_images+=N_i |
| |
| # latents checks |
| iflatentsisnotNone: |
| ifnottorch.is_tensor(latents): |
| raiseValueError("`latents` must be a torch.Tensor.") |
| iflatents.dim() !=4: |
| raiseValueError(f"`latents` has unsupported dimensions or shape: {latents.shape}.") |
| |
| ifprocessing_resolution>0: |
| max_orig=max(H, W) |
| new_H=H*processing_resolution//max_orig |
| new_W=W*processing_resolution//max_orig |
| ifnew_H==0ornew_W==0: |
| raiseValueError(f"Extreme aspect ratio of the input image: [{W} x {H}]") |
| W, H=new_W, new_H |
| w= (W+self.vae_scale_factor-1) //self.vae_scale_factor |
| h= (H+self.vae_scale_factor-1) //self.vae_scale_factor |
| shape_expected= (num_images*ensemble_size, self.vae.config.latent_channels, h, w) |
| |
| iflatents.shape!=shape_expected: |
| raiseValueError(f"`latents` has unexpected shape={latents.shape} expected={shape_expected}.") |
Problem:
check_inputs() reads every tensor/array size with img.shape[-2:]. That is correct for torch CHW/NCHW, but wrong for NumPy HWC/NHWC. A valid latent for a (32, 64, 3) NumPy image should be (1, 4, 4, 8), but validation expects (1, 4, 8, 1).
Impact:
Users following the documented output_latent=True reuse path can round-trip PIL/torch inputs, but NumPy image inputs reject their own valid latents or allow invalid latents that fail later in denoising.
Reproduction:
importnumpyasnpimporttorchfromtypesimportSimpleNamespacefromdiffusersimportMarigoldDepthPipelinepipe=object.__new__(MarigoldDepthPipeline)
pipe.vae_scale_factor=8pipe.vae=SimpleNamespace(config=SimpleNamespace(block_out_channels=[1, 1, 1, 1], latent_channels=4))
pipe.scale_invariant=Falsepipe.shift_invariant=Falsepipe.check_inputs(
image=np.zeros((32, 64, 3), dtype=np.float32),
num_inference_steps=1,
ensemble_size=1,
processing_resolution=0,
resample_method_input="bilinear",
resample_method_output="bilinear",
batch_size=1,
ensembling_kwargs=None,
latents=torch.zeros(1, 4, 4, 8),
generator=None,
output_type="pt",
output_uncertainty=False,
)
Relevant precedent:
MarigoldImageProcessor.load_image_canonical() already treats NumPy as HWC/NHWC before converting to NCHW.
| ifisinstance(image, np.ndarray): |
| ifnp.issubdtype(image.dtype, np.integer) andnotnp.issubdtype(image.dtype, np.unsignedinteger): |
| raiseValueError(f"Input image dtype={image.dtype} cannot be a signed integer.") |
| ifnp.issubdtype(image.dtype, np.complexfloating): |
| raiseValueError(f"Input image dtype={image.dtype} cannot be complex.") |
| ifnp.issubdtype(image.dtype, bool): |
| raiseValueError(f"Input image dtype={image.dtype} cannot be boolean.") |
| ifnp.issubdtype(image.dtype, np.unsignedinteger): |
| image_dtype_max=np.iinfo(image.dtype).max |
| image=image.astype(np.float32) # because torch does not have unsigned dtypes beyond torch.uint8 |
| image=MarigoldImageProcessor.numpy_to_pt(image) |
Suggested fix:
ifisinstance(img, np.ndarray):
ifimg.ndim==2:
H_i, W_i=img.shapeN_i=1elifimg.ndim==3:
H_i, W_i=img.shape[:2]
N_i=1else:
N_i, H_i, W_i=img.shape[:3]
else:
H_i, W_i=img.shape[-2:]
N_i=img.shape[0] ifimg.ndim==4else1
Issue 2: Generator lists are reused unsliced across Marigold manual batches
Affected code:
| foriinself.progress_bar( |
| range(0, num_images*ensemble_size, batch_size), leave=True, desc="Marigold predictions..." |
| ): |
| batch_image_latent=image_latent[i : i+batch_size] # [B,4,h,w] |
| batch_pred_latent=pred_latent[i : i+batch_size] # [B,4,h,w] |
| effective_batch_size=batch_image_latent.shape[0] |
| text=batch_empty_text_embedding[:effective_batch_size] # [B,2,1024] |
| |
| self.scheduler.set_timesteps(num_inference_steps, device=device) |
| fortinself.progress_bar(self.scheduler.timesteps, leave=False, desc="Diffusion steps..."): |
| batch_latent=torch.cat([batch_image_latent, batch_pred_latent], dim=1) # [B,8,h,w] |
| noise=self.unet(batch_latent, t, encoder_hidden_states=text, return_dict=False)[0] # [B,4,h,w] |
| batch_pred_latent=self.scheduler.step( |
| noise, t, batch_pred_latent, generator=generator |
| ).prev_sample# [B,4,h,w] |
| foriinself.progress_bar( |
| range(0, num_images*ensemble_size, batch_size), leave=True, desc="Marigold predictions..." |
| ): |
| batch_image_latent=image_latent[i : i+batch_size] # [B,4,h,w] |
| batch_pred_latent=pred_latent[i : i+batch_size] # [B,T*4,h,w] |
| effective_batch_size=batch_image_latent.shape[0] |
| text=batch_empty_text_embedding[:effective_batch_size] # [B,2,1024] |
| |
| self.scheduler.set_timesteps(num_inference_steps, device=device) |
| fortinself.progress_bar(self.scheduler.timesteps, leave=False, desc="Diffusion steps..."): |
| batch_latent=torch.cat([batch_image_latent, batch_pred_latent], dim=1) # [B,(1+T)*4,h,w] |
| noise=self.unet(batch_latent, t, encoder_hidden_states=text, return_dict=False)[0] # [B,T*4,h,w] |
| batch_pred_latent=self.scheduler.step( |
| noise, t, batch_pred_latent, generator=generator |
| ).prev_sample# [B,T*4,h,w] |
| foriinself.progress_bar( |
| range(0, num_images*ensemble_size, batch_size), leave=True, desc="Marigold predictions..." |
| ): |
| batch_image_latent=image_latent[i : i+batch_size] # [B,4,h,w] |
| batch_pred_latent=pred_latent[i : i+batch_size] # [B,4,h,w] |
| effective_batch_size=batch_image_latent.shape[0] |
| text=batch_empty_text_embedding[:effective_batch_size] # [B,2,1024] |
| |
| self.scheduler.set_timesteps(num_inference_steps, device=device) |
| fortinself.progress_bar(self.scheduler.timesteps, leave=False, desc="Diffusion steps..."): |
| batch_latent=torch.cat([batch_image_latent, batch_pred_latent], dim=1) # [B,8,h,w] |
| noise=self.unet(batch_latent, t, encoder_hidden_states=text, return_dict=False)[0] # [B,4,h,w] |
| batch_pred_latent=self.scheduler.step( |
| noise, t, batch_pred_latent, generator=generator |
| ).prev_sample# [B,4,h,w] |
Problem:
The pipelines validate a generator list of length num_images * ensemble_size, then process predictions in smaller manual batches. Each scheduler step receives the full list instead of the current slice. LCMScheduler.step() samples per-step noise, and randn_tensor() uses only the first shape[0] generators, so later batches reuse the wrong generators.
Impact:
Batched Marigold LCM inference is not equivalent to separate seeded calls when batch_size < num_images * ensemble_size and num_inference_steps > 1.
Reproduction:
importtorchfromtransformersimportCLIPTextConfig, CLIPTextModel, CLIPTokenizerfromdiffusersimportAutoencoderTiny, LCMScheduler, MarigoldDepthPipeline, UNet2DConditionModeldefmake_pipe():
torch.manual_seed(0)
unet=UNet2DConditionModel(
block_out_channels=(32, 64), layers_per_block=1, sample_size=4,
in_channels=8, out_channels=4,
down_block_types=("DownBlock2D", "CrossAttnDownBlock2D"),
up_block_types=("CrossAttnUpBlock2D", "UpBlock2D"),
cross_attention_dim=32,
)
vae=AutoencoderTiny(in_channels=3, out_channels=3, latent_channels=4)
scheduler=LCMScheduler(prediction_type="v_prediction", beta_schedule="scaled_linear")
text_encoder=CLIPTextModel(CLIPTextConfig(
bos_token_id=0, eos_token_id=2, hidden_size=32, intermediate_size=37,
num_attention_heads=4, num_hidden_layers=1, pad_token_id=1, vocab_size=1000,
))
tokenizer=CLIPTokenizer.from_pretrained("hf-internal-testing/tiny-random-clip")
pipe=MarigoldDepthPipeline(unet, vae, scheduler, text_encoder, tokenizer, "depth", True, True).to("cpu")
pipe.set_progress_bar_config(disable=True)
returnpipedefgen(seed):
returntorch.Generator(device="cpu").manual_seed(seed)
image=torch.full((1, 3, 32, 32), 0.5)
batched=make_pipe()(image=[image[0], image[0]], num_inference_steps=2, processing_resolution=0,
batch_size=1, generator=[gen(0), gen(1)], output_type="pt").predictionsingle_seed_1=make_pipe()(image=image, num_inference_steps=2, processing_resolution=0,
generator=gen(1), output_type="pt").predictionprint((batched[1] -single_seed_1[0]).abs().max().item()) # non-zeroRelevant precedent:
randn_tensor() consumes generator lists by batch position, so callers must pass a list matching the current batch.
| batch_size=shape[0] |
| |
| layout=layoutortorch.strided |
| device=deviceortorch.device("cpu") |
| |
| ifgeneratorisnotNone: |
| gen_device_type=generator.device.typeifnotisinstance(generator, list) elsegenerator[0].device.type |
| ifgen_device_type!=device.typeandgen_device_type=="cpu": |
| rand_device="cpu" |
| ifdevice!="mps": |
| logger.info( |
| f"The passed generator was created on 'cpu' even though a tensor on {device} was expected." |
| f" Tensors will be created on 'cpu' and then moved to {device}. Note that one can probably" |
| f" slightly speed up this function by passing a generator that was created on the {device} device." |
| ) |
| elifgen_device_type!=device.typeandgen_device_type=="cuda": |
| raiseValueError(f"Cannot generate a {device} tensor from a generator of type {gen_device_type}.") |
| |
| # make sure generator list of length 1 is treated like a non-list |
| ifisinstance(generator, list) andlen(generator) ==1: |
| generator=generator[0] |
| |
| ifisinstance(generator, list): |
| shape= (1,) +shape[1:] |
| latents= [ |
| torch.randn(shape, generator=generator[i], device=rand_device, dtype=dtype, layout=layout) |
| foriinrange(batch_size) |
| ] |
| latents=torch.cat(latents, dim=0).to(device) |
Suggested fix:
batch_generator=generatorifisinstance(generator, list):
batch_generator=generator[i : i+effective_batch_size]
batch_pred_latent=self.scheduler.step(
noise, t, batch_pred_latent, generator=batch_generator
).prev_sample
Issue 3: Absolute depth ensembling is documented but always raises
Affected code:
| requires_aligning=scale_invariantorshift_invariant |
| ensemble_size=depth.shape[0] |
| |
| ifrequires_aligning: |
| param=compute_param(depth) |
| depth=align(depth, param) |
| |
| depth, uncertainty=ensemble(depth, return_uncertainty=output_uncertainty) |
| |
| depth_max=depth.max() |
| ifscale_invariantandshift_invariant: |
| depth_min=depth.min() |
| elifscale_invariant: |
| depth_min=0 |
| else: |
| raiseValueError("Unrecognized alignment.") |
| depth_range= (depth_max-depth_min).clamp(min=1e-6) |
| depth= (depth-depth_min) /depth_range |
| ifoutput_uncertainty: |
| uncertainty/=depth_range |
| |
| returndepth, uncertainty# [1,1,H,W], [1,1,H,W] |
Problem:
The ensemble_depth() docstring says absolute predictions (scale_invariant=False, shift_invariant=False) skip alignment and only ensemble, but the post-ensemble normalization branch raises ValueError("Unrecognized alignment.") whenever scale_invariant is false.
Impact:
Any absolute-depth Marigold checkpoint config can run single predictions, but ensemble_size > 1 crashes.
Reproduction:
importtorchfromdiffusersimportMarigoldDepthPipelinedepth=torch.rand(3, 1, 8, 8)
MarigoldDepthPipeline.ensemble_depth(
depth,
scale_invariant=False,
shift_invariant=False,
output_uncertainty=True,
reduction="mean",
)
Relevant precedent:
The method’s own docstring describes absolute-prediction ensembling as supported.
| Ensembles the depth maps represented by the `depth` tensor with expected shape `(B, 1, H, W)`, where B is the |
| number of ensemble members for a given prediction of size `(H x W)`. Even though the function is designed for |
| depth maps, it can also be used with disparity maps as long as the input tensor values are non-negative. The |
| alignment happens when the predictions have one or more degrees of freedom, that is when they are either |
| affine-invariant (`scale_invariant=True` and `shift_invariant=True`), or just scale-invariant (only |
| `scale_invariant=True`). For absolute predictions (`scale_invariant=False` and `shift_invariant=False`) |
| alignment is skipped and only ensembling is performed. |
Suggested fix:
ifscale_invariant:
depth_max=depth.max()
depth_min=depth.min() ifshift_invariantelse0depth_range= (depth_max-depth_min).clamp(min=1e-6)
depth= (depth-depth_min) /depth_rangeifoutput_uncertainty:
uncertainty/=depth_range
Issue 4: Visualization helpers advertise list[np.ndarray] but list paths assume torch tensors
Affected code:
| defvisualize_normals( |
| normals: np.ndarray|torch.Tensor|list[np.ndarray] |list[torch.Tensor], |
| flip_x: bool=False, |
| flip_y: bool=False, |
| flip_z: bool=False, |
| ) ->list[PIL.Image.Image]: |
| """ |
| Visualizes surface normals, such as predictions of the `MarigoldNormalsPipeline`. |
| |
| Args: |
| normals (`np.ndarray | torch.Tensor | list[np.ndarray, list[torch.Tensor]]`): |
| Surface normals. |
| flip_x (`bool`, *optional*, defaults to `False`): Flips the X axis of the normals frame of reference. |
| Default direction is right. |
| flip_y (`bool`, *optional*, defaults to `False`): Flips the Y axis of the normals frame of reference. |
| Default direction is top. |
| flip_z (`bool`, *optional*, defaults to `False`): Flips the Z axis of the normals frame of reference. |
| Default direction is facing the observer. |
| |
| Returns: `list[PIL.Image.Image]` with surface normals visualization. |
| """ |
| flip_vec=None |
| ifany((flip_x, flip_y, flip_z)): |
| flip_vec=torch.tensor( |
| [ |
| (-1) **flip_x, |
| (-1) **flip_y, |
| (-1) **flip_z, |
| ], |
| dtype=torch.float32, |
| ) |
| |
| defvisualize_normals_one(img, idx=None): |
| img=img.permute(1, 2, 0) |
| ifflip_vecisnotNone: |
| img*=flip_vec.to(img.device) |
| img= (img+1.0) *0.5 |
| img= (img*255).to(dtype=torch.uint8, device="cpu").numpy() |
| img=PIL.Image.fromarray(img) |
| returnimg |
| |
| ifnormalsisNoneorisinstance(normals, list) andany(oisNoneforoinnormals): |
| raiseValueError("Input normals is `None`") |
| ifisinstance(normals, (np.ndarray, torch.Tensor)): |
| normals=MarigoldImageProcessor.expand_tensor_or_array(normals) |
| ifisinstance(normals, np.ndarray): |
| normals=MarigoldImageProcessor.numpy_to_pt(normals) # [N,3,H,W] |
| ifnot (normals.ndim==4andnormals.shape[1] ==3): |
| raiseValueError(f"Unexpected input shape={normals.shape}, expecting [N,3,H,W].") |
| return [visualize_normals_one(img, idx) foridx, imginenumerate(normals)] |
| elifisinstance(normals, list): |
| return [visualize_normals_one(img, idx) foridx, imginenumerate(normals)] |
| defvisualize_intrinsics( |
| prediction: np.ndarray|torch.Tensor|list[np.ndarray] |list[torch.Tensor], |
| target_properties: dict[str, Any], |
| color_map: str|dict[str, str] ="binary", |
| ) ->list[dict[str, PIL.Image.Image]]: |
| """ |
| Visualizes intrinsic image decomposition, such as predictions of the `MarigoldIntrinsicsPipeline`. |
| |
| Args: |
| prediction (`np.ndarray | torch.Tensor | list[np.ndarray, list[torch.Tensor]]`): |
| Intrinsic image decomposition. |
| target_properties (`dict[str, Any]`): |
| Decomposition properties. Expected entries: `target_names: list[str]` and a dictionary with keys |
| `prediction_space: str`, `sub_target_names: list[str | Null]` (must have 3 entries, null for missing |
| modalities), `up_to_scale: bool`, one for each target and sub-target. |
| color_map (`str | dict[str, str]`, *optional*, defaults to `"Spectral"`): |
| Color map used to convert a single-channel predictions into colored representations. When a dictionary |
| is passed, each modality can be colored with its own color map. |
| |
| Returns: `list[dict[str, PIL.Image.Image]]` with intrinsic image decomposition visualization. |
| """ |
| if"target_names"notintarget_properties: |
| raiseValueError("Missing `target_names` in target_properties") |
| ifnotisinstance(color_map, str) andnot ( |
| isinstance(color_map, dict) |
| andall(isinstance(k, str) andisinstance(v, str) fork, vincolor_map.items()) |
| ): |
| raiseValueError("`color_map` must be a string or a dictionary of strings") |
| n_targets=len(target_properties["target_names"]) |
| |
| defvisualize_targets_one(images, idx=None): |
| # img: [T, 3, H, W] |
| out= {} |
| fortarget_name, imginzip(target_properties["target_names"], images): |
| img=img.permute(1, 2, 0) # [H, W, 3] |
| prediction_space=target_properties[target_name].get("prediction_space", "srgb") |
| ifprediction_space=="stack": |
| sub_target_names=target_properties[target_name]["sub_target_names"] |
| iflen(sub_target_names) !=3orany( |
| not (isinstance(s, str) orsisNone) forsinsub_target_names |
| ): |
| raiseValueError(f"Unexpected target sub-names {sub_target_names} in {target_name}") |
| fori, sub_target_nameinenumerate(sub_target_names): |
| ifsub_target_nameisNone: |
| continue |
| sub_img=img[:, :, i] |
| sub_prediction_space=target_properties[sub_target_name].get("prediction_space", "srgb") |
| ifsub_prediction_space=="linear": |
| sub_up_to_scale=target_properties[sub_target_name].get("up_to_scale", False) |
| ifsub_up_to_scale: |
| sub_img=sub_img/max(sub_img.max().item(), 1e-6) |
| sub_img=sub_img** (1/2.2) |
| cmap_name= ( |
| color_mapifisinstance(color_map, str) elsecolor_map.get(sub_target_name, "binary") |
| ) |
| sub_img=MarigoldImageProcessor.colormap(sub_img, cmap=cmap_name, bytes=True) |
| sub_img=PIL.Image.fromarray(sub_img.cpu().numpy()) |
| out[sub_target_name] =sub_img |
| elifprediction_space=="linear": |
| up_to_scale=target_properties[target_name].get("up_to_scale", False) |
| ifup_to_scale: |
| img=img/max(img.max().item(), 1e-6) |
| img=img** (1/2.2) |
| elifprediction_space=="srgb": |
| pass |
| img= (img*255).to(dtype=torch.uint8, device="cpu").numpy() |
| img=PIL.Image.fromarray(img) |
| out[target_name] =img |
| returnout |
| |
| ifpredictionisNoneorisinstance(prediction, list) andany(oisNoneforoinprediction): |
| raiseValueError("Input prediction is `None`") |
| ifisinstance(prediction, (np.ndarray, torch.Tensor)): |
| prediction=MarigoldImageProcessor.expand_tensor_or_array(prediction) |
| ifisinstance(prediction, np.ndarray): |
| prediction=MarigoldImageProcessor.numpy_to_pt(prediction) # [N*T,3,H,W] |
| ifnot (prediction.ndim==4andprediction.shape[1] ==3andprediction.shape[0] %n_targets==0): |
| raiseValueError(f"Unexpected input shape={prediction.shape}, expecting [N*T,3,H,W].") |
| N_T, _, H, W=prediction.shape |
| N=N_T//n_targets |
| prediction=prediction.reshape(N, n_targets, 3, H, W) |
| return [visualize_targets_one(img, idx) foridx, imginenumerate(prediction)] |
| elifisinstance(prediction, list): |
| return [visualize_targets_one(img, idx) foridx, imginenumerate(prediction)] |
| defvisualize_uncertainty( |
| uncertainty: np.ndarray|torch.Tensor|list[np.ndarray] |list[torch.Tensor], |
| saturation_percentile=95, |
| ) ->list[PIL.Image.Image]: |
| """ |
| Visualizes dense uncertainties, such as produced by `MarigoldDepthPipeline`, `MarigoldNormalsPipeline`, or |
| `MarigoldIntrinsicsPipeline`. |
| |
| Args: |
| uncertainty (`np.ndarray | torch.Tensor | list[np.ndarray, list[torch.Tensor]]`): |
| Uncertainty maps. |
| saturation_percentile (`int`, *optional*, defaults to `95`): |
| Specifies the percentile uncertainty value visualized with maximum intensity. |
| |
| Returns: `list[PIL.Image.Image]` with uncertainty visualization. |
| """ |
| |
| defvisualize_uncertainty_one(img, idx=None): |
| prefix="Uncertainty"+ (f"[{idx}]"ifidxelse"") |
| ifimg.min() <0: |
| raiseValueError(f"{prefix}: unexpected data range, min={img.min()}.") |
| img=img.permute(1, 2, 0) # [H,W,C] |
| img=img.squeeze(2).cpu().numpy() # [H,W] or [H,W,3] |
| saturation_value=np.percentile(img, saturation_percentile) |
| img=np.clip(img*255/saturation_value, 0, 255) |
| img=img.astype(np.uint8) |
| img=PIL.Image.fromarray(img) |
| returnimg |
| |
| ifuncertaintyisNoneorisinstance(uncertainty, list) andany(oisNoneforoinuncertainty): |
| raiseValueError("Input uncertainty is `None`") |
| ifisinstance(uncertainty, (np.ndarray, torch.Tensor)): |
| uncertainty=MarigoldImageProcessor.expand_tensor_or_array(uncertainty) |
| ifisinstance(uncertainty, np.ndarray): |
| uncertainty=MarigoldImageProcessor.numpy_to_pt(uncertainty) # [N,C,H,W] |
| ifnot (uncertainty.ndim==4anduncertainty.shape[1] in (1, 3)): |
| raiseValueError(f"Unexpected input shape={uncertainty.shape}, expecting [N,C,H,W] with C in (1,3).") |
| return [visualize_uncertainty_one(img, idx) foridx, imginenumerate(uncertainty)] |
| elifisinstance(uncertainty, list): |
| return [visualize_uncertainty_one(img, idx) foridx, imginenumerate(uncertainty)] |
Problem:
visualize_normals(), visualize_intrinsics(), and visualize_uncertainty() accept list[np.ndarray] in their annotations/docstrings, but their list branches call helpers that immediately use tensor-only methods like .permute().
Impact:
Batch arrays work, but equivalent lists of arrays fail with AttributeError, which is a public API mismatch for post-processing utilities.
Reproduction:
importnumpyasnpfromdiffusers.pipelines.marigoldimportMarigoldImageProcessorMarigoldImageProcessor.visualize_normals([np.zeros((4, 4, 3), dtype=np.float32)])
Relevant precedent:
visualize_depth() handles list elements individually, and the non-list branches of these helpers already know how to convert NumPy arrays.
| defvisualize_depth_one(img, idx=None): |
| prefix="Depth"+ (f"[{idx}]"ifidxelse"") |
| ifisinstance(img, PIL.Image.Image): |
| ifimg.mode!="I;16": |
| raiseValueError(f"{prefix}: invalid PIL mode={img.mode}.") |
| img=np.array(img).astype(np.float32) / (2**16-1) |
| ifisinstance(img, np.ndarray) ortorch.is_tensor(img): |
| ifimg.ndim!=2: |
| raiseValueError(f"{prefix}: unexpected shape={img.shape}.") |
| ifisinstance(img, np.ndarray): |
| img=torch.from_numpy(img) |
| ifnottorch.is_floating_point(img): |
| raiseValueError(f"{prefix}: unexpected dtype={img.dtype}.") |
| else: |
| raiseValueError(f"{prefix}: unexpected type={type(img)}.") |
| ifval_min!=0.0orval_max!=1.0: |
| img= (img-val_min) / (val_max-val_min) |
| img=MarigoldImageProcessor.colormap(img, cmap=color_map, bytes=True) # [H,W,3] |
| img=PIL.Image.fromarray(img.cpu().numpy()) |
| returnimg |
| |
| ifdepthisNoneorisinstance(depth, list) andany(oisNoneforoindepth): |
| raiseValueError("Input depth is `None`") |
| ifisinstance(depth, (np.ndarray, torch.Tensor)): |
| depth=MarigoldImageProcessor.expand_tensor_or_array(depth) |
| ifisinstance(depth, np.ndarray): |
| depth=MarigoldImageProcessor.numpy_to_pt(depth) # [N,H,W,1] -> [N,1,H,W] |
| ifnot (depth.ndim==4anddepth.shape[1] ==1): # [N,1,H,W] |
| raiseValueError(f"Unexpected input shape={depth.shape}, expecting [N,1,H,W].") |
| return [visualize_depth_one(img[0], idx) foridx, imginenumerate(depth)] |
| elifisinstance(depth, list): |
| return [visualize_depth_one(img, idx) foridx, imginenumerate(depth)] |
Suggested fix:
elifisinstance(normals, list):
return [
outforiteminnormalsforoutinMarigoldImageProcessor.visualize_normals(item, flip_x=flip_x, flip_y=flip_y, flip_z=flip_z)
]
Apply the same recursive list handling to visualize_intrinsics() and visualize_uncertainty().
Duplicate-search status
Searched GitHub issues and PRs for marigold, the affected class/function/file names, and the specific failure modes above. I found broad Marigold integration/docs items, but no duplicate issues or PRs for these four findings.
Test coverage status
Fast and slow tests exist for depth, normals, and intrinsics under tests/pipelines/marigold/. The gaps are the cases above: NumPy HWC latent reuse, generator-list batching with multi-step LCM, absolute-depth ensembling, and list-of-NumPy visualization inputs.
marigoldmodel/pipeline reviewCommit tested:
0f1abc4ae8b0eb2a3b40e82a310507281144c423Review performed against the repository review rules.
Issue 1: NumPy HWC images validate returned latents against the wrong shape
Affected code:
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_depth.py
Lines 273 to 312 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_intrinsics.py
Lines 285 to 324 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_normals.py
Lines 258 to 297 in 0f1abc4
Problem:
check_inputs()reads every tensor/array size withimg.shape[-2:]. That is correct for torch CHW/NCHW, but wrong for NumPy HWC/NHWC. A valid latent for a(32, 64, 3)NumPy image should be(1, 4, 4, 8), but validation expects(1, 4, 8, 1).Impact:
Users following the documented
output_latent=Truereuse path can round-trip PIL/torch inputs, but NumPy image inputs reject their own valid latents or allow invalid latents that fail later in denoising.Reproduction:
Relevant precedent:
MarigoldImageProcessor.load_image_canonical()already treats NumPy as HWC/NHWC before converting to NCHW.diffusers/src/diffusers/pipelines/marigold/marigold_image_processing.py
Lines 175 to 185 in 0f1abc4
Suggested fix:
Issue 2: Generator lists are reused unsliced across Marigold manual batches
Affected code:
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_depth.py
Lines 514 to 528 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_intrinsics.py
Lines 520 to 534 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_normals.py
Lines 491 to 505 in 0f1abc4
Problem:
The pipelines validate a generator list of length
num_images * ensemble_size, then process predictions in smaller manual batches. Each scheduler step receives the full list instead of the current slice.LCMScheduler.step()samples per-step noise, andrandn_tensor()uses only the firstshape[0]generators, so later batches reuse the wrong generators.Impact:
Batched Marigold LCM inference is not equivalent to separate seeded calls when
batch_size < num_images * ensemble_sizeandnum_inference_steps > 1.Reproduction:
Relevant precedent:
randn_tensor()consumes generator lists by batch position, so callers must pass a list matching the current batch.diffusers/src/diffusers/utils/torch_utils.py
Lines 167 to 195 in 0f1abc4
Suggested fix:
Issue 3: Absolute depth ensembling is documented but always raises
Affected code:
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_depth.py
Lines 806 to 827 in 0f1abc4
Problem:
The
ensemble_depth()docstring says absolute predictions (scale_invariant=False,shift_invariant=False) skip alignment and only ensemble, but the post-ensemble normalization branch raisesValueError("Unrecognized alignment.")wheneverscale_invariantis false.Impact:
Any absolute-depth Marigold checkpoint config can run single predictions, but
ensemble_size > 1crashes.Reproduction:
Relevant precedent:
The method’s own docstring describes absolute-prediction ensembling as supported.
diffusers/src/diffusers/pipelines/marigold/pipeline_marigold_depth.py
Lines 685 to 691 in 0f1abc4
Suggested fix:
Issue 4: Visualization helpers advertise list[np.ndarray] but list paths assume torch tensors
Affected code:
diffusers/src/diffusers/pipelines/marigold/marigold_image_processing.py
Lines 487 to 538 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/marigold_image_processing.py
Lines 543 to 626 in 0f1abc4
diffusers/src/diffusers/pipelines/marigold/marigold_image_processing.py
Lines 631 to 670 in 0f1abc4
Problem:
visualize_normals(),visualize_intrinsics(), andvisualize_uncertainty()acceptlist[np.ndarray]in their annotations/docstrings, but their list branches call helpers that immediately use tensor-only methods like.permute().Impact:
Batch arrays work, but equivalent lists of arrays fail with
AttributeError, which is a public API mismatch for post-processing utilities.Reproduction:
Relevant precedent:
visualize_depth()handles list elements individually, and the non-list branches of these helpers already know how to convert NumPy arrays.diffusers/src/diffusers/pipelines/marigold/marigold_image_processing.py
Lines 415 to 446 in 0f1abc4
Suggested fix:
Apply the same recursive list handling to
visualize_intrinsics()andvisualize_uncertainty().Duplicate-search status
Searched GitHub issues and PRs for
marigold, the affected class/function/file names, and the specific failure modes above. I found broad Marigold integration/docs items, but no duplicate issues or PRs for these four findings.Test coverage status
Fast and slow tests exist for depth, normals, and intrinsics under
tests/pipelines/marigold/. The gaps are the cases above: NumPy HWC latent reuse, generator-list batching with multi-step LCM, absolute-depth ensembling, and list-of-NumPy visualization inputs.