From ccd226f0886261d38ac89a3b7aeea0f1aff6c19f Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 10 Sep 2026 14:59:25 -0700 Subject: [PATCH 1/5] fix --- zeromodels/base/base_image_processor.py | 17 +++++------ .../models/beit/beit_image_processor.py | 15 +++++----- .../models/dfine/dfine_image_processor.py | 2 -- .../models/eomt/eomt_image_processor.py | 2 -- .../mask2former_image_processor.py | 2 -- .../maskformer/maskformer_image_processor.py | 2 -- .../models/oneformer/oneformer_processor.py | 2 -- zeromodels/models/sam/sam_image_processor.py | 4 --- .../models/sam2/sam2_image_processor.py | 4 --- .../segformer/segformer_image_processor.py | 15 ++++++---- zeromodels/utils/image_util.py | 28 +++++++++++++++++-- 11 files changed, 52 insertions(+), 41 deletions(-) diff --git a/zeromodels/base/base_image_processor.py b/zeromodels/base/base_image_processor.py index f71fdd7b..b44d576a 100644 --- a/zeromodels/base/base_image_processor.py +++ b/zeromodels/base/base_image_processor.py @@ -136,20 +136,21 @@ def call(self, image): if isinstance(image, (str, Image.Image)): image = ops.cast(ops.convert_to_tensor(load_image(image)), "float32") else: - image = ops.convert_to_tensor(image) - if len(image.shape) == 4: - image = image[0] - image = ops.cast(image, "float32") + image = ops.cast(ops.convert_to_tensor(image), "float32") max_v = float(ops.convert_to_numpy(ops.max(image))) min_v = float(ops.convert_to_numpy(ops.min(image))) if max_v <= 1.0 and min_v >= 0.0: image = image * 255.0 elif min_v < 0 or max_v > 255: raise ValueError("Tensor values must be in [0, 1] or [0, 255] range") - if len(image.shape) != 3: - raise ValueError("Input image must have shape (H, W, C)") - - image = ops.expand_dims(image, axis=0) + rank = len(image.shape) + if rank == 3: + image = ops.expand_dims(image, axis=0) + elif rank != 4: + raise ValueError( + "Input image must have shape (H, W, C) or a batch (B, H, W, C); " + f"got rank {rank}." + ) if self.do_resize: image = ops.image.resize( image, diff --git a/zeromodels/models/beit/beit_image_processor.py b/zeromodels/models/beit/beit_image_processor.py index ce5a51df..227e1670 100644 --- a/zeromodels/models/beit/beit_image_processor.py +++ b/zeromodels/models/beit/beit_image_processor.py @@ -125,19 +125,20 @@ def call(self, image): if get_data_format(self.data_format) == "channels_first": image = ops.transpose(image, (0, 3, 1, 2)) else: - image = ops.convert_to_tensor(image) - if len(image.shape) == 4: - image = image[0] - if len(image.shape) != 3: - raise ValueError("Input tensor must have shape (H, W, C)") - image = ops.cast(image, "float32") + image = ops.cast(ops.convert_to_tensor(image), "float32") max_v = float(ops.convert_to_numpy(ops.max(image))) min_v = float(ops.convert_to_numpy(ops.min(image))) if max_v <= 1.0 and min_v >= 0.0: image = image * 255.0 elif min_v < 0 or max_v > 255: raise ValueError("Tensor values must be in [0, 1] or [0, 255] range") - image = ops.expand_dims(image, axis=0) + rank = len(image.shape) + if rank == 3: + image = ops.expand_dims(image, axis=0) + elif rank != 4: + raise ValueError( + "Input tensor must have shape (H, W, C) or (B, H, W, C)." + ) if self.do_resize: target = (self.size["height"], self.size["width"]) if tuple(image.shape[1:3]) != target: diff --git a/zeromodels/models/dfine/dfine_image_processor.py b/zeromodels/models/dfine/dfine_image_processor.py index fe6a3131..63b90927 100644 --- a/zeromodels/models/dfine/dfine_image_processor.py +++ b/zeromodels/models/dfine/dfine_image_processor.py @@ -83,8 +83,6 @@ def __call__(self, image: Union[str, np.ndarray, "Image.Image"]): def call(self, image: Union[str, np.ndarray, "Image.Image", List]): if isinstance(image, (list, tuple)): return self.stack_images(image) - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] arr = load_image(image) pil_img = Image.fromarray(arr) target_wh = (self.size["width"], self.size["height"]) diff --git a/zeromodels/models/eomt/eomt_image_processor.py b/zeromodels/models/eomt/eomt_image_processor.py index a10af089..3a464283 100644 --- a/zeromodels/models/eomt/eomt_image_processor.py +++ b/zeromodels/models/eomt/eomt_image_processor.py @@ -71,8 +71,6 @@ def __call__( def call( self, image: Union[str, np.ndarray, Image.Image] ) -> Dict[str, keras.KerasTensor]: - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32) h, w = image.shape[:2] diff --git a/zeromodels/models/mask2former/mask2former_image_processor.py b/zeromodels/models/mask2former/mask2former_image_processor.py index 617bd44d..8b352333 100644 --- a/zeromodels/models/mask2former/mask2former_image_processor.py +++ b/zeromodels/models/mask2former/mask2former_image_processor.py @@ -96,8 +96,6 @@ def __call__(self, image): return self.call(image) def call(self, image): - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32) h, w = image.shape[:2] diff --git a/zeromodels/models/maskformer/maskformer_image_processor.py b/zeromodels/models/maskformer/maskformer_image_processor.py index 8fb9e702..4f2a44d2 100644 --- a/zeromodels/models/maskformer/maskformer_image_processor.py +++ b/zeromodels/models/maskformer/maskformer_image_processor.py @@ -65,8 +65,6 @@ def __call__(self, image) -> Dict[str, keras.KerasTensor]: return self.call(image) def call(self, image) -> Dict[str, keras.KerasTensor]: - if hasattr(image, "ndim") and image.ndim == 4: - image = image[0] image = load_image(image) h, w = image.shape[:2] diff --git a/zeromodels/models/oneformer/oneformer_processor.py b/zeromodels/models/oneformer/oneformer_processor.py index 8054687a..5197f79d 100644 --- a/zeromodels/models/oneformer/oneformer_processor.py +++ b/zeromodels/models/oneformer/oneformer_processor.py @@ -46,8 +46,6 @@ def __init__( self.data_format = data_format def call(self, image): - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32) h, w = image.shape[:2] diff --git a/zeromodels/models/sam/sam_image_processor.py b/zeromodels/models/sam/sam_image_processor.py index 6c2aac24..1e528a49 100644 --- a/zeromodels/models/sam/sam_image_processor.py +++ b/zeromodels/models/sam/sam_image_processor.py @@ -62,8 +62,6 @@ def __call__( def call( self, image: Union[str, np.ndarray, "Image.Image"] ) -> Dict[str, "keras.KerasTensor"]: - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32) orig_h, orig_w = image.shape[:2] @@ -406,8 +404,6 @@ def generate_crop_boxes( (all ones: foreground). - ``"original_size"``: ``(orig_h, orig_w)``. """ - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32, copy=False) image = keras.ops.convert_to_tensor(image, dtype="float32") diff --git a/zeromodels/models/sam2/sam2_image_processor.py b/zeromodels/models/sam2/sam2_image_processor.py index ed4d4fa0..3744b7c7 100644 --- a/zeromodels/models/sam2/sam2_image_processor.py +++ b/zeromodels/models/sam2/sam2_image_processor.py @@ -59,8 +59,6 @@ def __call__( def call( self, image: Union[str, np.ndarray, "Image.Image"] ) -> Dict[str, "keras.KerasTensor"]: - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] image = load_image(image).astype(np.float32) orig_h, orig_w = image.shape[:2] @@ -256,8 +254,6 @@ def load_image_to_numpy(image: Union[str, np.ndarray, "Image.Image"]) -> np.ndar optional leading batch axis and casts to float32, matching the dtype the rest of the Sam2 pipeline expects. """ - if isinstance(image, np.ndarray) and image.ndim == 4: - image = image[0] return load_image(image).astype(np.float32, copy=False) diff --git a/zeromodels/models/segformer/segformer_image_processor.py b/zeromodels/models/segformer/segformer_image_processor.py index cc90b92b..2ed64c12 100644 --- a/zeromodels/models/segformer/segformer_image_processor.py +++ b/zeromodels/models/segformer/segformer_image_processor.py @@ -106,11 +106,6 @@ def call( and hasattr(image, "dtype") ) if is_keras_tensor: - if len(image.shape) == 4: - image = image[0] - if len(image.shape) != 3: - raise ValueError("Input tensor must have shape (H, W, C)") - image_float = keras.ops.cast(image, dtype="float32") max_val_py = keras.ops.convert_to_numpy(keras.ops.max(image_float)).item() min_val_py = keras.ops.convert_to_numpy(keras.ops.min(image_float)).item() @@ -122,7 +117,15 @@ def call( else: image = image_float - image = keras.ops.expand_dims(image, axis=0) + # A single image (H, W, C) gets a batch axis; a batch (B, H, W, C) is + # kept whole, never silently reduced to its first image. + rank = len(image.shape) + if rank == 3: + image = keras.ops.expand_dims(image, axis=0) + elif rank != 4: + raise ValueError( + "Input tensor must have shape (H, W, C) or (B, H, W, C)." + ) if self.do_resize: target_size = (self.size["height"], self.size["width"]) if image.shape[1:3] != target_size: diff --git a/zeromodels/utils/image_util.py b/zeromodels/utils/image_util.py index e814f9ec..0558bb93 100644 --- a/zeromodels/utils/image_util.py +++ b/zeromodels/utils/image_util.py @@ -114,10 +114,25 @@ def load_image(image: ImageInput) -> np.ndarray: * ``PIL.Image.Image``: returned as a copy converted to RGB. * ``np.ndarray``: assumed to already be an HWC RGB image. 2D arrays are broadcast across 3 channels; 4-channel arrays are truncated to - RGB; float arrays in [0, 1] are scaled to uint8. + RGB. A ``(1, H, W, C)`` array is unwrapped to a single image; a real + batch ``(N, H, W, C)`` with ``N > 1`` raises (pass a list to batch). + Float arrays auto-detect their range: values in [0, 1] are scaled to + [0, 255], values already in [0, 255] (e.g. from + ``keras.utils.img_to_array``) are kept as-is; anything outside those + ranges raises. """ if isinstance(image, np.ndarray): arr = image + if arr.ndim == 4: + # A leading batch axis of 1 is a single image; a real batch (N > 1) + # must be passed as a list so no image is silently dropped. + if arr.shape[0] != 1: + raise ValueError( + f"load_image got a batch of {arr.shape[0]} images (shape " + f"{arr.shape}); pass a single image, or a list of images to " + "preprocess a batch." + ) + arr = arr[0] if arr.ndim == 2: arr = np.stack([arr, arr, arr], axis=-1) if arr.ndim != 3: @@ -127,7 +142,16 @@ def load_image(image: ImageInput) -> np.ndarray: if arr.shape[-1] != 3: raise ValueError(f"Expected 3 channels, got shape {arr.shape}.") if np.issubdtype(arr.dtype, np.floating): - arr = np.clip(arr * 255.0, 0, 255).astype(np.uint8) + max_v = float(arr.max()) if arr.size else 0.0 + min_v = float(arr.min()) if arr.size else 0.0 + if max_v <= 1.0 and min_v >= 0.0: + arr = arr * 255.0 + elif min_v < 0.0 or max_v > 255.0: + raise ValueError( + "float image values must be in [0, 1] or [0, 255]; got " + f"[{min_v:.4g}, {max_v:.4g}]." + ) + arr = np.clip(arr, 0, 255).astype(np.uint8) elif arr.dtype != np.uint8: arr = arr.astype(np.uint8) return arr From b4c4fb5abb9a182a322536d5cbf0ff80007925a8 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 10 Sep 2026 15:35:04 -0700 Subject: [PATCH 2/5] fix-detr-tabletransformers --- zeromodels/base/base_image_processor.py | 88 +++++++++++++++++++ .../models/detr/detr_image_processor.py | 22 +++-- zeromodels/models/detr/detr_model.py | 6 +- .../table_transformer_image_processor.py | 25 ++++-- .../table_transformer_model.py | 6 +- zeromodels/utils/image_util.py | 14 ++- 6 files changed, 139 insertions(+), 22 deletions(-) diff --git a/zeromodels/base/base_image_processor.py b/zeromodels/base/base_image_processor.py index b44d576a..9c1a15cb 100644 --- a/zeromodels/base/base_image_processor.py +++ b/zeromodels/base/base_image_processor.py @@ -516,3 +516,91 @@ def preprocess_image( x = ops.transpose(x, (0, 3, 1, 2)) return x, original_sizes, (target_h, target_w), data_format + + @staticmethod + def resize_shortest_longest(h, w, shortest_edge, longest_edge): + """Aspect-preserving target ``(H, W)``: scale the short side to + ``shortest_edge``, then cap so the long side does not exceed + ``longest_edge`` (the reference DETR ``get_size_with_aspect_ratio``). + The long side is derived from the rounded short side so a square input + stays square. + """ + short, long_ = min(h, w), max(h, w) + new_short = shortest_edge + if longest_edge is not None and long_ * (new_short / short) > longest_edge: + new_short = int(round(longest_edge * short / long_)) + new_long = int(round(new_short * long_ / short)) + return (new_short, new_long) if h <= w else (new_long, new_short) + + @staticmethod + def preprocess_image_variable( + images, + shortest_edge, + longest_edge, + image_mean=None, + image_std=None, + rescale=True, + interpolation="bilinear", + antialias=False, + data_format=None, + ): + """Aspect-preserving resize + rescale + normalize, matching the reference + DETR pipeline: each image is resized so its short side is ``shortest_edge`` + (capped at ``longest_edge``), then a batch is zero-padded to the common max + size. A single image is not padded, so single-image inference is exact; a + padded multi-image batch is approximate (this port has no ``pixel_mask`` + to hide the padding from attention, unlike the reference). + """ + data_format = get_data_format(data_format) + if isinstance(images, (list, tuple)): + items = list(images) + elif isinstance(images, np.ndarray) and images.ndim == 4: + items = [images[i] for i in range(images.shape[0])] + else: + items = [images] + if not items: + raise ValueError("`images` must contain at least one image.") + + loaded = [load_image(img) for img in items] + original_sizes = [(int(a.shape[0]), int(a.shape[1])) for a in loaded] + + mean = std = None + if image_mean is not None: + if image_std is None: + raise ValueError("image_std must be provided when image_mean is set.") + mean = ops.reshape(ops.convert_to_tensor(image_mean, "float32"), (1, 1, 3)) + std = ops.reshape(ops.convert_to_tensor(image_std, "float32"), (1, 1, 3)) + + resized = [] + for arr in loaded: + th, tw = BaseImageProcessor.resize_shortest_longest( + int(arr.shape[0]), int(arr.shape[1]), shortest_edge, longest_edge + ) + t = ops.expand_dims(ops.convert_to_tensor(arr, "float32"), 0) + t = ops.image.resize( + t, + size=(th, tw), + interpolation=interpolation, + antialias=antialias, + data_format="channels_last", + )[0] + if rescale: + t = t / 255.0 + if mean is not None: + t = (t - mean) / std + resized.append(t) + + max_h = max(int(t.shape[0]) for t in resized) + max_w = max(int(t.shape[1]) for t in resized) + batch = [] + for t in resized: + h, w = int(t.shape[0]), int(t.shape[1]) + if h != max_h or w != max_w: + t = ops.pad(t, [[0, max_h - h], [0, max_w - w], [0, 0]]) + batch.append(ops.expand_dims(t, 0)) + x = ops.concatenate(batch, axis=0) + + if data_format == "channels_first": + x = ops.transpose(x, (0, 3, 1, 2)) + + return x, original_sizes, (max_h, max_w), data_format diff --git a/zeromodels/models/detr/detr_image_processor.py b/zeromodels/models/detr/detr_image_processor.py index 522664dc..79fcb696 100644 --- a/zeromodels/models/detr/detr_image_processor.py +++ b/zeromodels/models/detr/detr_image_processor.py @@ -16,8 +16,12 @@ class DETRImageProcessor(BaseImageProcessor): first. Args: - size: Target size as ``{"height": H, "width": W}``. - Default: ``{"height": 800, "width": 800}``. + size: Aspect-preserving resize spec + ``{"shortest_edge": S, "longest_edge": L}``: the short side is scaled + to ``S``, capped so the long side does not exceed ``L``. Default: + ``{"shortest_edge": 800, "longest_edge": 1333}`` (the reference + DetrImageProcessor). Requires a model built with a dynamic input + (``image_size=None``, the default). resample: Interpolation method (``"nearest"``, ``"bilinear"``, or ``"bicubic"``). do_rescale: Whether to divide pixel values by 255. @@ -47,7 +51,9 @@ def __init__( **kwargs, ): super().__init__(**kwargs) - self.size = size if size is not None else {"height": 800, "width": 800} + self.size = ( + size if size is not None else {"shortest_edge": 800, "longest_edge": 1333} + ) self.resample = resample self.do_rescale = do_rescale self.rescale_factor = rescale_factor @@ -67,11 +73,13 @@ def __call__( def call( self, image: Union[str, np.ndarray, Image.Image, List] ) -> Dict[str, Union[keras.KerasTensor, np.ndarray]]: - if isinstance(image, (list, tuple)): - return self.stack_images(image) - image, _, _, _ = self.preprocess_image( + # Aspect-preserving resize (shortest_edge / longest_edge), matching the + # reference DetrImageProcessor. A batch is zero-padded to the common max + # size (single-image inference is exact; see preprocess_image_variable). + image, _, _, _ = self.preprocess_image_variable( image, - target_size=(self.size["height"], self.size["width"]), + shortest_edge=self.size["shortest_edge"], + longest_edge=self.size["longest_edge"], image_mean=self.image_mean if self.do_normalize else None, image_std=self.image_std if self.do_normalize else None, rescale=self.do_rescale, diff --git a/zeromodels/models/detr/detr_model.py b/zeromodels/models/detr/detr_model.py index efd7b628..d54b87b4 100644 --- a/zeromodels/models/detr/detr_model.py +++ b/zeromodels/models/detr/detr_model.py @@ -468,12 +468,14 @@ def __init__( dim_feedforward=2048, dropout_rate=0.1, num_queries=100, - image_size=800, + image_size=None, input_tensor=None, name="DetrModel", **kwargs, ): data_format = keras.config.image_data_format() + # None -> dynamic (None, None, 3): the reference resizes aspect-preserving + # to variable sizes, which the size-agnostic DETR graph accepts. image_size = standardize_input_shape(image_size, data_format) if input_tensor is None: @@ -594,7 +596,7 @@ def __init__( dropout_rate=0.1, num_queries=100, num_classes=92, - image_size=800, + image_size=None, input_tensor=None, name="DETRDetect", **kwargs, diff --git a/zeromodels/models/table_transformer/table_transformer_image_processor.py b/zeromodels/models/table_transformer/table_transformer_image_processor.py index 3135974e..eed98188 100644 --- a/zeromodels/models/table_transformer/table_transformer_image_processor.py +++ b/zeromodels/models/table_transformer/table_transformer_image_processor.py @@ -22,12 +22,15 @@ class TableTransformerImageProcessor(BaseImageProcessor): The model takes already-normalized input, so run pixels through this processor first. Mirrors the reference Detr image processor the Table Transformer checkpoints - ship with: rescale to `[0, 1]`, resize to a square `size`, and apply - ImageNet normalization. + ship with: aspect-preserving resize (short side to ``shortest_edge``, capped + at ``longest_edge``), rescale to `[0, 1]`, and ImageNet normalization. Args: - size: Target size as ``{"height": H, "width": W}``. Default: - ``{"height": 800, "width": 800}``. + size: Aspect-preserving resize spec + ``{"shortest_edge": S, "longest_edge": L}``. Default: + ``{"shortest_edge": 800, "longest_edge": 1333}`` (the reference). + Requires a model built with a dynamic input (``image_size=None``, + the default). resample: Interpolation method (``"nearest"``, ``"bilinear"``, or ``"bicubic"``). do_rescale: Whether to divide pixel values by 255. @@ -56,7 +59,9 @@ def __init__( **kwargs, ): super().__init__(**kwargs) - self.size = size if size is not None else {"height": 800, "width": 800} + self.size = ( + size if size is not None else {"shortest_edge": 800, "longest_edge": 1333} + ) self.resample = resample self.do_rescale = do_rescale self.rescale_factor = rescale_factor @@ -72,11 +77,13 @@ def __call__(self, image) -> Dict[str, keras.KerasTensor]: return self.call(image) def call(self, image) -> Dict[str, keras.KerasTensor]: - if isinstance(image, (list, tuple)): - return self.stack_images(image) - image, _, _, _ = self.preprocess_image( + # Aspect-preserving resize (shortest_edge / longest_edge), matching the + # reference. A batch is zero-padded to the common max size (single-image + # inference is exact; see preprocess_image_variable). + image, _, _, _ = self.preprocess_image_variable( image, - target_size=(self.size["height"], self.size["width"]), + shortest_edge=self.size["shortest_edge"], + longest_edge=self.size["longest_edge"], image_mean=self.image_mean if self.do_normalize else None, image_std=self.image_std if self.do_normalize else None, rescale=self.do_rescale, diff --git a/zeromodels/models/table_transformer/table_transformer_model.py b/zeromodels/models/table_transformer/table_transformer_model.py index 3819d794..84f93b69 100644 --- a/zeromodels/models/table_transformer/table_transformer_model.py +++ b/zeromodels/models/table_transformer/table_transformer_model.py @@ -422,12 +422,14 @@ def __init__( dim_feedforward=2048, dropout_rate=0.1, num_queries=15, - image_size=800, + image_size=None, input_tensor=None, name="TableTransformerModel", **kwargs, ): data_format = keras.config.image_data_format() + # None -> dynamic (None, None, 3): the reference resizes aspect-preserving + # to variable sizes, which the size-agnostic graph accepts. image_size = standardize_input_shape(image_size, data_format) if input_tensor is None: @@ -561,7 +563,7 @@ def __init__( dropout_rate=0.1, num_queries=15, num_classes=3, - image_size=800, + image_size=None, input_tensor=None, name="TableTransformerDetect", **kwargs, diff --git a/zeromodels/utils/image_util.py b/zeromodels/utils/image_util.py index 0558bb93..af1b2932 100644 --- a/zeromodels/utils/image_util.py +++ b/zeromodels/utils/image_util.py @@ -45,17 +45,27 @@ def standardize_input_shape( * ``(H, W, C)`` or ``(C, H, W)``: already a 3-tuple. The channel dimension (``C in {1, 3, 4}``) must sit in the position required by the active data format; mismatches raise ``ValueError``. + * ``None`` (or a shape already containing ``None``): a dynamic, + variable-size input, ``(None, None, 3)`` / ``(3, None, None)``, for a + size-agnostic model (e.g. DETR, which resizes aspect-preserving to + variable sizes). Args: - image_size: Flexible spec, int, 2-tuple, or 3-tuple. + image_size: Flexible spec: int, 2-tuple, 3-tuple, or ``None`` (dynamic). data_format: ``"channels_first"`` / ``"channels_last"`` / ``None``. ``None`` defaults to ``keras.config.image_data_format()``. Returns: - A length-3 tuple ordered to match the resolved ``data_format``. + A length-3 tuple ordered to match the resolved ``data_format`` (its H/W + entries are ``None`` for a dynamic input). """ data_format = get_data_format(data_format) + if image_size is None or ( + isinstance(image_size, (tuple, list)) and None in tuple(image_size) + ): + return (None, None, 3) if data_format == "channels_last" else (3, None, None) + if isinstance(image_size, int): if image_size <= 0: raise ValueError(f"image_size int must be positive, got {image_size}.") From 854a2942f8f08259338769514075cd82aea21dd6 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 10 Sep 2026 15:41:03 -0700 Subject: [PATCH 3/5] fix --- .../grounding_dino/grounding_dino_image_processor.py | 7 ++++++- .../models/qwen2_vl/qwen2_vl_image_processor.py | 7 ++++++- zeromodels/utils/image_util.py | 12 +++++++++++- 3 files changed, 23 insertions(+), 3 deletions(-) diff --git a/zeromodels/models/grounding_dino/grounding_dino_image_processor.py b/zeromodels/models/grounding_dino/grounding_dino_image_processor.py index 0d8d4bc7..9550981e 100644 --- a/zeromodels/models/grounding_dino/grounding_dino_image_processor.py +++ b/zeromodels/models/grounding_dino/grounding_dino_image_processor.py @@ -49,10 +49,15 @@ def _to_rgb(self, image): from PIL import Image + from zeromodels.utils.image_util import load_image + if isinstance(image, (str, os.PathLike)): image = Image.open(image) elif not isinstance(image, Image.Image): - image = Image.fromarray(np.asarray(image).astype("uint8")) + # load_image handles the value range (float [0,1] -> [0,255], [0,255] + # kept, out-of-range raises), matching every other processor instead + # of truncating a [0,1] array to black. + image = Image.fromarray(load_image(image)) return image.convert("RGB") def _preprocess_one(self, image): diff --git a/zeromodels/models/qwen2_vl/qwen2_vl_image_processor.py b/zeromodels/models/qwen2_vl/qwen2_vl_image_processor.py index ad2d1895..6282d885 100644 --- a/zeromodels/models/qwen2_vl/qwen2_vl_image_processor.py +++ b/zeromodels/models/qwen2_vl/qwen2_vl_image_processor.py @@ -80,10 +80,15 @@ def _to_rgb_array(self, image): from PIL import Image + from zeromodels.utils.image_util import load_image + if isinstance(image, (str, os.PathLike)): image = Image.open(image) elif not isinstance(image, Image.Image): - image = Image.fromarray(np.asarray(image).astype("uint8")) + # load_image handles the value range (float [0,1] -> [0,255], [0,255] + # kept, out-of-range raises), matching every other processor instead + # of truncating a [0,1] array to black. + image = Image.fromarray(load_image(image)) return image.convert("RGB") def _preprocess_one(self, image): diff --git a/zeromodels/utils/image_util.py b/zeromodels/utils/image_util.py index af1b2932..83c9e9dd 100644 --- a/zeromodels/utils/image_util.py +++ b/zeromodels/utils/image_util.py @@ -129,7 +129,9 @@ def load_image(image: ImageInput) -> np.ndarray: Float arrays auto-detect their range: values in [0, 1] are scaled to [0, 255], values already in [0, 255] (e.g. from ``keras.utils.img_to_array``) are kept as-is; anything outside those - ranges raises. + ranges raises. Non-uint8 integer arrays must already be in [0, 255] + (a higher-bit-depth image, e.g. a 16-bit TIFF, raises; convert it to + uint8 first) so values are never wrapped modulo 256. """ if isinstance(image, np.ndarray): arr = image @@ -163,6 +165,14 @@ def load_image(image: ImageInput) -> np.ndarray: ) arr = np.clip(arr, 0, 255).astype(np.uint8) elif arr.dtype != np.uint8: + max_v = int(arr.max()) if arr.size else 0 + min_v = int(arr.min()) if arr.size else 0 + if min_v < 0 or max_v > 255: + raise ValueError( + "integer image values must be in [0, 255]; got " + f"[{min_v}, {max_v}]. Convert a higher-bit-depth image " + "(e.g. a 16-bit TIFF) to uint8 first." + ) arr = arr.astype(np.uint8) return arr From 2e3ed62d99a0dda96c04c1a1e88be043b619f6d4 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 10 Sep 2026 15:50:30 -0700 Subject: [PATCH 4/5] Add get config --- zeromodels/base/base_mixin.py | 40 +++++++++++++++++++++++++++++++++-- 1 file changed, 38 insertions(+), 2 deletions(-) diff --git a/zeromodels/base/base_mixin.py b/zeromodels/base/base_mixin.py index 1e84fb86..a83e0a04 100644 --- a/zeromodels/base/base_mixin.py +++ b/zeromodels/base/base_mixin.py @@ -1089,10 +1089,46 @@ class PreprocessorMixin(keras.layers.Layer): Subclasses (:class:`BaseTokenizer`, :class:`BaseProcessor`, :class:`BaseImageProcessor`, :class:`BaseAudioFeatureExtractor`) implement - ``call`` and add their own state / ``get_config``: the base bakes in no - defaults. + ``call``; the base bakes in no defaults but provides a generic + ``get_config`` / ``from_config`` (below) so a Keras save/reload round-trips + the constructor state without every subclass hand-writing one. """ + def get_config(self): + """Serialize the constructor state so a Keras save/reload round-trips. + + Keras's default ``Layer.get_config`` returns only ``{name, trainable, + dtype}`` and would silently drop every real argument (e.g. a CLIP + processor's ``image_resolution``), so read the values straight off the + instance by walking the constructor signature: each named parameter maps + to a same-named attribute (``self.image_resolution``, ``self.size``, ...), + falling back to the parameter default; ``self`` and ``*args`` / + ``**kwargs`` are skipped. A subclass with unusual state (e.g. + :class:`BaseProcessor`, whose sub-components are Keras objects) still + overrides this. + """ + from zeromodels.conversion.zm_config import _jsonable + + config = {} + for name, param in inspect.signature(type(self).__init__).parameters.items(): + if name == "self" or param.kind in ( + param.VAR_POSITIONAL, + param.VAR_KEYWORD, + ): + continue + # A processor may transform its argument (e.g. a mean tuple into a + # tensor); _jsonable turns that back into a serializable form the + # constructor re-accepts (tensor -> list, numpy -> python). + if hasattr(self, name): + config[name] = _jsonable(getattr(self, name)) + elif param.default is not inspect.Parameter.empty: + config[name] = _jsonable(param.default) + return config + + @classmethod + def from_config(cls, config): + return cls(**config) + @classmethod def from_weights(cls, identifier, **kwargs): if identifier.startswith("hf:"): From b9d664ffdee1b8ff3ff60a33fcbb04598cea57d5 Mon Sep 17 00:00:00 2001 From: IMvision12 Date: Thu, 10 Sep 2026 16:03:26 -0700 Subject: [PATCH 5/5] fix --- .../models/kimi_k25/kimi_k25_processor.py | 14 ++++++++--- .../models/kimi_k25/kimi_k25_tokenizer.py | 16 +++++++++---- .../speech2text/speech2text_processor.py | 5 ++-- .../speech2text/speech2text_tokenizer.py | 23 +++++++++++-------- 4 files changed, 38 insertions(+), 20 deletions(-) diff --git a/zeromodels/models/kimi_k25/kimi_k25_processor.py b/zeromodels/models/kimi_k25/kimi_k25_processor.py index bd96d70a..37183f3e 100644 --- a/zeromodels/models/kimi_k25/kimi_k25_processor.py +++ b/zeromodels/models/kimi_k25/kimi_k25_processor.py @@ -18,8 +18,14 @@ class KimiK25Processor(BaseProcessor): embedding lookup and scatters the projected patches back in. Args: - tokenizer / image_processor: Pre-built components, or omit them to construct - the defaults. + hf_id: Hub repo the default tokenizer pulls ``tiktoken.model`` from (all + three Kimi checkpoints share one tokenizer). There is no default repo, + so bare ``KimiK25Processor()`` raises via the tokenizer: pass an + ``hf_id``, a pre-built ``tokenizer``, or load by repo id with + ``from_weights``. + tokenizer / image_processor: Pre-built components; omit them to build the + defaults (the image processor is pure config; the tokenizer needs + ``hf_id``). """ TOKENIZER_CLS = KimiK25Tokenizer @@ -28,12 +34,14 @@ class KimiK25Processor(BaseProcessor): def __init__( self, + hf_id=None, tokenizer=None, image_processor=None, **kwargs, ): super().__init__(**kwargs) - self.tokenizer = tokenizer or KimiK25Tokenizer() + self.hf_id = hf_id + self.tokenizer = tokenizer or KimiK25Tokenizer(hf_id=hf_id) self.image_processor = image_processor or KimiK25ImageProcessor() @classmethod diff --git a/zeromodels/models/kimi_k25/kimi_k25_tokenizer.py b/zeromodels/models/kimi_k25/kimi_k25_tokenizer.py index c66f88c6..3635358d 100644 --- a/zeromodels/models/kimi_k25/kimi_k25_tokenizer.py +++ b/zeromodels/models/kimi_k25/kimi_k25_tokenizer.py @@ -4,7 +4,6 @@ from zeromodels.base import BaseTokenizer -DEFAULT_HF_ID = "moonshotai/Kimi-K2.6" VOCAB_FILE = "tiktoken.model" NUM_RESERVED_SPECIAL_TOKENS = 256 @@ -84,14 +83,21 @@ class KimiK25Tokenizer(BaseTokenizer): Args: vocab_file: Path to a local ``tiktoken.model``. hf_id: Hub repo to pull ``tiktoken.model`` from when ``vocab_file`` is - omitted. + omitted. Required (with ``vocab_file``): there is no default repo, so + ``KimiK25Tokenizer()`` with neither raises. All three Kimi + checkpoints share one tokenizer, so any of their repos works. """ - HF_ID = DEFAULT_HF_ID - def __init__(self, vocab_file=None, hf_id=None, **kwargs): super().__init__(**kwargs) - self.hf_id = hf_id or self.HF_ID + if vocab_file is None and hf_id is None: + raise ValueError( + f"{type(self).__name__}() needs a vocab_file or hf_id: there is no " + "default repo. Load it by repo id, e.g. " + "from_weights('hf:moonshotai/Kimi-K2.6') (all three Kimi " + "checkpoints share one tokenizer), or pass a local vocab_file." + ) + self.hf_id = hf_id self.vocab_file = vocab_file or self.download_vocab(self.hf_id) ranks = load_bpe_ranks(self.vocab_file) diff --git a/zeromodels/models/speech2text/speech2text_processor.py b/zeromodels/models/speech2text/speech2text_processor.py index 20119af0..8b6f9da2 100644 --- a/zeromodels/models/speech2text/speech2text_processor.py +++ b/zeromodels/models/speech2text/speech2text_processor.py @@ -24,8 +24,9 @@ class Speech2TextProcessor(BaseProcessor): Speech2Text uses to seed autoregressive decoding. Args: - vocab_file / spm_file: Tokenizer files. Downloaded from the HF repo - when ``None``. + vocab_file / spm_file: Tokenizer files. There is no default repo, so bare + construction with neither raises via the tokenizer: load by repo id + with ``from_weights`` (which downloads both), or pass the files. sampling_rate / num_mel_bins: Forwarded to the feature extractor. do_upper_case / do_lower_case: Forwarded to the tokenizer. decoder_start_token_id: Seed token id for generation (```` = 2). diff --git a/zeromodels/models/speech2text/speech2text_tokenizer.py b/zeromodels/models/speech2text/speech2text_tokenizer.py index 0a8f0c6c..b034e215 100644 --- a/zeromodels/models/speech2text/speech2text_tokenizer.py +++ b/zeromodels/models/speech2text/speech2text_tokenizer.py @@ -26,22 +26,20 @@ class Speech2TextTokenizer(BaseTokenizer): The tokenizer is a pair of files (``vocab.json`` + ``spm.model``), so it is downloaded by Hub repo id like weights: pass a repo to ``from_weights("zeromodels/s2t-small-librispeech-asr")`` (all S2T variants - share one vocab), or explicit ``vocab_file`` / ``spm_file`` paths. With no - args it falls back to the default repo below. + share one vocab), or explicit ``vocab_file`` and ``spm_file`` paths. There is + no default repo, so ``Speech2TextTokenizer()`` with neither raises. Args: - vocab_file: Path to ``vocab.json`` (token -> id). Downloaded from the - default repo when ``None``. - spm_file: Path to the SentencePiece ``.model`` file. Downloaded when - ``None``. + vocab_file: Path to ``vocab.json`` (token -> id). Required together with + ``spm_file`` (or load by repo id, which downloads both). + spm_file: Path to the SentencePiece ``.model`` file. Required together + with ``vocab_file``. do_upper_case: Upper-case the decoded text (multilingual ST variants). do_lower_case: Lower-case the input text before encoding. max_seq_len: Maximum target length (used when padding label ids). bos_token / eos_token / pad_token / unk_token: Special token strings. """ - DEFAULT_REPO = "zeromodels/s2t-small-librispeech-asr" - @classmethod def _download_pair(cls, repo_id): from huggingface_hub import hf_hub_download @@ -87,8 +85,13 @@ def __init__( **kwargs, ): super().__init__(**kwargs) - if vocab_file is None and spm_file is None: - vocab_file, spm_file = type(self)._download_pair(type(self).DEFAULT_REPO) + if vocab_file is None or spm_file is None: + raise ValueError( + f"{type(self).__name__}() needs both vocab_file and spm_file: there " + "is no default repo. Load it by repo id, e.g. " + "from_weights('zeromodels/s2t-small-librispeech-asr') (all S2T " + "variants share one vocab), which downloads both." + ) self.vocab_file = vocab_file self.spm_file = spm_file self.do_upper_case = do_upper_case