Skip to content

marigold model/pipeline review #13628

Description

@hlky

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-zero

Relevant 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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions