Skip to content

Commit 9b91b43

Browse files
authored
[LLM runner] Support HWC inputs in make_image_input (pytorch#22888)
## Summary - add proper HWC and 1xHWC support to `make_image_input` - convert HWC tensors to CHW internally - keep existing CHW behavior and improve validation/docs - add coverage for all supported layouts, dtypes, and invalid inputs Fixes pytorch#22685 ## Test plan - `python -m pytest -q extension/llm/runner/test/test_runner_pybindings.py` - 14 passed cc @larryliu0820 @mergennachin @cccclai @helunwencser @jackzhxng @digantdesai
1 parent 067632d commit 9b91b43

4 files changed

Lines changed: 101 additions & 29 deletions

File tree

‎extension/llm/runner/README.md‎

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -274,10 +274,14 @@ token_ids = [1, 15043, 445, 2420] # Example token IDs
274274
token_input = make_token_input(token_ids)
275275

276276
# 3. Image input from torch tensor
277-
# Supports channels-first (C,H,W), or (1,C,H,W) with batch size 1
277+
# CHW is the default, including when width is 3 or 4
278278
image_chw = torch.randint(0, 255, (3, 224, 224), dtype=torch.uint8)
279279
image_input = make_image_input(image_chw)
280280

281+
# HWC inputs require an explicit layout, including when height is 3 or 4
282+
image_hwc = torch.randint(0, 255, (224, 224, 3), dtype=torch.uint8)
283+
image_input_hwc = make_image_input(image_hwc, layout="HWC")
284+
281285
# Float tensors also supported for normalized images
282286
image_float = torch.rand(3, 224, 224, dtype=torch.float32) # CHW, normalized
283287
image_input_float = make_image_input(image_float)

‎extension/llm/runner/_llm_runner.pyi‎

Lines changed: 10 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -553,18 +553,25 @@ def make_text_input(text: str) -> MultimodalInput:
553553
"""
554554
...
555555

556-
def make_image_input(image_tensor: torch.Tensor) -> MultimodalInput:
556+
def make_image_input(
557+
image_tensor: torch.Tensor, layout: str = "CHW"
558+
) -> MultimodalInput:
557559
"""
558560
Create an image input from a torch tensor.
559561
560562
Args:
561-
image_tensor: Torch tensor with shape (H, W, C), (1, H, W, C), (C, H, W), or (1, C, H, W)
563+
image_tensor: Contiguous uint8 or float32 tensor with an optional batch
564+
dimension of size 1.
565+
layout: Tensor layout, either "CHW" or "HWC". Defaults to "CHW" to
566+
preserve existing behavior. HWC inputs are converted to CHW
567+
internally, including when height is 3 or 4.
562568
563569
Returns:
564570
A MultimodalInput containing the image
565571
566572
Raises:
567-
RuntimeError: If the tensor has invalid dimensions or number of channels
573+
RuntimeError: If the tensor has invalid dimensions, layout, dtype, or
574+
number of channels
568575
"""
569576
...
570577

‎extension/llm/runner/pybindings.cpp‎

Lines changed: 17 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -571,7 +571,8 @@ PYBIND11_MODULE(_llm_runner, m) {
571571

572572
m.def(
573573
"make_image_input",
574-
[](torch::Tensor image_tensor) -> MultimodalInput {
574+
[](torch::Tensor image_tensor,
575+
const std::string& layout) -> MultimodalInput {
575576
if (image_tensor.dim() == 4) {
576577
if (image_tensor.size(0) != 1) {
577578
throw std::runtime_error(
@@ -582,30 +583,31 @@ PYBIND11_MODULE(_llm_runner, m) {
582583

583584
if (image_tensor.dim() != 3) {
584585
throw std::runtime_error(
585-
"Image tensor must be 3-dimensional (H, W, C) or 4-dimensional (1, H, W, C)");
586+
"Image tensor must have shape (H, W, C), (1, H, W, C), (C, H, W), or (1, C, H, W)");
587+
}
588+
589+
if (!image_tensor.is_contiguous()) {
590+
throw std::runtime_error("Image tensor must be contiguous");
586591
}
587592

588593
int64_t height, width, channels;
589-
// Check for memory format and permute to CHW if necessary
590-
if (image_tensor.is_contiguous(at::MemoryFormat::ChannelsLast)) {
591-
// Input is HWC, permute to CHW
594+
if (layout == "CHW") {
595+
channels = image_tensor.size(0);
596+
height = image_tensor.size(1);
597+
width = image_tensor.size(2);
598+
} else if (layout == "HWC") {
592599
height = image_tensor.size(0);
593600
width = image_tensor.size(1);
594601
channels = image_tensor.size(2);
595602
image_tensor = image_tensor.permute({2, 0, 1});
596-
} else if (image_tensor.is_contiguous(at::MemoryFormat::Contiguous)) {
597-
// Input is CHW
598-
channels = image_tensor.size(0);
599-
height = image_tensor.size(1);
600-
width = image_tensor.size(2);
601603
} else {
602604
throw std::runtime_error(
603-
"Image tensor must be contiguous in either channels last (H, W, C) or contiguous (C, H, W) format.");
605+
"Image tensor layout must be 'CHW' or 'HWC'");
604606
}
605607

606608
if (channels != 3 && channels != 4) {
607609
throw std::runtime_error(
608-
"Image must have 3 (RGB) or 4 (RGBA) channels");
610+
"Image must have 3 (RGB) or 4 (RGBA) channels in the dimension specified by layout");
609611
}
610612

611613
image_tensor = image_tensor.contiguous();
@@ -630,8 +632,9 @@ PYBIND11_MODULE(_llm_runner, m) {
630632
"Unsupported image tensor dtype. Only uint8 and float32 are supported.");
631633
}
632634
},
633-
"Create an image input from a torch tensor (H, W, C), (1, H, W, C), (C, H, W), or (1, C, H, W)",
634-
py::arg("image_tensor"));
635+
"Create an image input from a contiguous uint8 or float32 torch tensor. layout must be 'CHW' or 'HWC' and defaults to 'CHW'.",
636+
py::arg("image_tensor"),
637+
py::arg("layout") = "CHW");
635638

636639
m.def(
637640
"make_audio_input",

‎extension/llm/runner/test/test_runner_pybindings.py‎

Lines changed: 69 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -183,7 +183,7 @@ def test_invalid_image_array(self):
183183
# Wrong dimensions (expects 3D or 4D tensor)
184184
with self.assertRaises(RuntimeError) as cm:
185185
make_image_input(torch.ones((100,), dtype=torch.uint8))
186-
self.assertIn("3-dimensional", str(cm.exception))
186+
self.assertIn("must have shape", str(cm.exception))
187187

188188
# Wrong number of channels
189189
with self.assertRaises(RuntimeError) as cm:
@@ -252,15 +252,73 @@ def test_make_text_input(self):
252252
self.assertEqual(text_input.get_text(), "Hello")
253253

254254
def test_make_image_input(self):
255-
"""Test make_image_input helper."""
256-
# Create a test image tensor (RGB, CHW)
257-
img_tensor = torch.zeros((3, 100, 150), dtype=torch.uint8)
258-
img_tensor[0, :, :] = 255 # Red channel
255+
"""Test supported image layouts and dtypes."""
256+
dimensions = ((3, 7), (4, 7), (5, 3), (5, 4), (5, 7))
257+
for height, width in dimensions:
258+
for channels in (3, 4):
259+
for dtype in (torch.uint8, torch.float32):
260+
chw = torch.arange(channels * height * width, dtype=dtype).reshape(
261+
channels, height, width
262+
)
263+
hwc = chw.permute(1, 2, 0).contiguous()
264+
layouts = {
265+
"chw_default": (chw, None),
266+
"1xchw": (chw.unsqueeze(0), "CHW"),
267+
"hwc": (hwc, "HWC"),
268+
"1xhwc": (hwc.unsqueeze(0), "HWC"),
269+
}
270+
271+
for name, (tensor, layout) in layouts.items():
272+
with self.subTest(
273+
channels=channels,
274+
dtype=dtype,
275+
height=height,
276+
width=width,
277+
layout=name,
278+
):
279+
image_input = (
280+
make_image_input(tensor)
281+
if layout is None
282+
else make_image_input(tensor, layout=layout)
283+
)
284+
self.assertTrue(image_input.is_image())
285+
image = image_input.get_image()
286+
self.assertEqual(image.channels, channels)
287+
self.assertEqual(image.height, height)
288+
self.assertEqual(image.width, width)
289+
expected_data = chw.flatten().tolist()
290+
actual_data = (
291+
image.uint8_data
292+
if dtype == torch.uint8
293+
else image.float_data
294+
)
295+
self.assertEqual(actual_data, expected_data)
296+
297+
def test_make_image_input_rejects_invalid_inputs(self):
298+
"""Test invalid image dimensions, layouts, and dtypes."""
299+
with self.assertRaisesRegex(RuntimeError, "must have shape"):
300+
make_image_input(torch.ones((100,), dtype=torch.uint8))
259301

260-
image_input = make_image_input(img_tensor)
261-
self.assertTrue(image_input.is_image())
302+
with self.assertRaisesRegex(RuntimeError, "Batch size"):
303+
make_image_input(torch.ones((2, 3, 5, 7), dtype=torch.uint8))
304+
305+
with self.assertRaisesRegex(RuntimeError, r"3 \(RGB\) or 4 \(RGBA\)"):
306+
make_image_input(torch.ones((2, 5, 7), dtype=torch.uint8))
307+
308+
with self.assertRaisesRegex(RuntimeError, r"3 \(RGB\) or 4 \(RGBA\)"):
309+
make_image_input(torch.ones((5, 7, 2), dtype=torch.uint8), layout="HWC")
262310

263-
# Test with RGBA (CHW)
264-
img_tensor_rgba = torch.ones((4, 50, 50), dtype=torch.uint8) * 128
265-
image_input_rgba = make_image_input(img_tensor_rgba)
266-
self.assertTrue(image_input_rgba.is_image())
311+
with self.assertRaisesRegex(RuntimeError, "must be 'CHW' or 'HWC'"):
312+
make_image_input(torch.ones((3, 5, 7), dtype=torch.uint8), layout="NHWC")
313+
314+
with self.assertRaisesRegex(RuntimeError, "Only uint8 and float32"):
315+
make_image_input(torch.ones((3, 5, 7), dtype=torch.int32))
316+
317+
noncontiguous_tensors = (
318+
(torch.ones((3, 5, 7), dtype=torch.uint8).transpose(1, 2), "CHW"),
319+
(torch.ones((5, 7, 3), dtype=torch.uint8).transpose(0, 1), "HWC"),
320+
)
321+
for tensor, layout in noncontiguous_tensors:
322+
with self.subTest(shape=tuple(tensor.shape)):
323+
with self.assertRaisesRegex(RuntimeError, "must be contiguous"):
324+
make_image_input(tensor, layout=layout)

0 commit comments

Comments
 (0)