Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
105 changes: 97 additions & 8 deletions zeromodels/base/base_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -515,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
40 changes: 38 additions & 2 deletions zeromodels/base/base_mixin.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:"):
Expand Down
15 changes: 8 additions & 7 deletions zeromodels/models/beit/beit_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
22 changes: 15 additions & 7 deletions zeromodels/models/detr/detr_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand All @@ -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,
Expand Down
6 changes: 4 additions & 2 deletions zeromodels/models/detr/detr_model.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down Expand Up @@ -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,
Expand Down
2 changes: 0 additions & 2 deletions zeromodels/models/dfine/dfine_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"])
Expand Down
2 changes: 0 additions & 2 deletions zeromodels/models/eomt/eomt_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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):
Expand Down
14 changes: 11 additions & 3 deletions zeromodels/models/kimi_k25/kimi_k25_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down
16 changes: 11 additions & 5 deletions zeromodels/models/kimi_k25/kimi_k25_tokenizer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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)
Expand Down
2 changes: 0 additions & 2 deletions zeromodels/models/mask2former/mask2former_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 0 additions & 2 deletions zeromodels/models/maskformer/maskformer_image_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
2 changes: 0 additions & 2 deletions zeromodels/models/oneformer/oneformer_processor.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down
Loading
Loading