diff --git a/README.md b/README.md
index 5fbf2212..2deaf27a 100644
--- a/README.md
+++ b/README.md
@@ -13,7 +13,7 @@
## 📖 Introduction
-ZeroModels is a collection of models with pretrained weights, built entirely with Keras 3. It supports a range of tasks, including classification, object detection (DETR, RT-DETR, RT-DETRv2, RF-DETR, D-FINE, EfficientDet, OWL-ViT, OWLv2, Grounding DINO), segmentation (SAM, SAM2, SAM3, SegFormer, DeepLabV3, EoMT, MaskFormer, Mask2Former, OneFormer, MobileViT-DeepLabV3, RF-DETR), monocular depth estimation (Depth Anything V1, Depth Anything V2, TIPSv2-DPT), feature extraction (DINO, DINOv2, DINOv3), vision-language modeling (CLIP, SigLIP, SigLIP2, MetaCLIP 2, TIPSv2), speech recognition (Whisper, Speech2Text, Moonshine, Granite Speech 5), speech-aware language modeling (Granite Speech, Granite Speech Plus), text encoding and masked language modeling (BERT, ModernBERT, ELECTRA, RoBERTa, XLM-RoBERTa, DeBERTa, DeBERTa-v2, DeBERTa-v3), text generation with large language models (GPT, GPT-2, Qwen2, Qwen2-MoE, Qwen3, Qwen3-MoE, Qwen3-Next, Qwen3.5, GPT-OSS, Llama 2, Llama 3, Llama 4, Mistral, Mixtral, Gemma, Gemma 2, MiniMax-Text-01, MiniMax-M2, DeepSeek-V2, DeepSeek-V3, DeepSeek-V4, GLM-4, GLM-4-0414, GLM-4.5/GLM-4.6, GLM-5/GLM-5.1/GLM-5.2), text-to-text encoder-decoder modeling (T5), multimodal vision-language generation (Qwen2-VL, Qwen2.5-VL, Qwen3-VL, Qwen3-VL-MoE, Qwen3.5-MoE, InternVL3, Gemma 3, Gemma 3n, Gemma 4, Gemma 4 Unified, Mistral 3, DeepSeek-VL, Janus-Pro, MiniMax-M3-VL, GLM-4V, GLM-4.5V, Kimi K2.5, Kimi K2.6, Kimi K2.7-Code), vision-language grounding across object detection, OCR, pointing, and referring (LocateAnything), and more. It includes hybrid architectures like MaxViT alongside traditional CNNs and pure transformers. zeromodels includes custom layers and backbone support, providing flexibility and efficiency across various applications. For backbones, there are various weight variants like `in1k`, `in21k`, `fb_dist_in1k`, `ms_in22k`, `fb_in22k_ft_in1k`, `ns_jft_in1k`, `aa_in1k`, `cvnets_in1k`, `augreg_in21k_ft_in1k`, `augreg_in21k`, and many more.
+ZeroModels is a collection of pretrained models built entirely in Keras 3. It spans a broad range of tasks, including image classification, object detection, segmentation, monocular depth estimation, feature extraction, vision-language modeling (VLMs), speech recognition, speech-aware language modeling, text encoding and masked language modeling, large language models (LLMs), text-to-text encoder-decoder modeling, multimodal vision-language generation, and more.
## âš¡ Installation
@@ -31,11 +31,14 @@ pip install -U git+https://github.com/IMvision12/ZeroModels
## 📑 Documentation
-📖 **[imvision12.github.io/ZeroModels](https://imvision12.github.io/ZeroModels/)** — the rendered docs, with search.
+**[ZeroModels Documentation](https://imvision12.github.io/ZeroModels/)**
-Per-model guides - with architecture notes, usage examples, and available pretrained weights, cover one page per model across every supported task (classification, object detection, segmentation, depth estimation, feature extraction, vision-language, speech recognition, text encoding, and language modeling). Classification backbones share a single [page](https://imvision12.github.io/ZeroModels/classification_backbones/) since they all follow the same `XModel` / `XImageClassify` two-class structure; each other model has its own. Every example on those pages prints its real, measured output.
+Detailed guides are available for all supported tasks, with architecture notes, usage examples, pretrained weights, and real model outputs.
+
+Classification backbones share a single [documentation page](https://imvision12.github.io/ZeroModels/classification_backbones/), while other model families have dedicated pages.
+
+Documentation sources are also available in [`docs/`](docs/).
-The Markdown sources live in [`docs/`](docs/) if you would rather read them in the repo.
## 📑 Models
@@ -142,6 +145,7 @@ The Markdown sources live in [`docs/`](docs/) if you would rather read them in t
|---------------|-------------------|---------------------|
| D-FINE | [D-FINE: Redefine Regression Task of DETRs as Fine-grained Distribution Refinement](https://arxiv.org/abs/2410.13842) | `transformers` |
| DETR | [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872) | `transformers` |
+ | Table Transformer | [PubTables-1M: Towards comprehensive table extraction from unstructured documents](https://arxiv.org/abs/2110.00061) | `transformers` |
| EfficientDet | [EfficientDet: Scalable and Efficient Object Detection](https://arxiv.org/abs/1911.09070) | `automl` |
| RT-DETR | [DETRs Beat YOLOs on Real-time Object Detection](https://arxiv.org/abs/2304.08069) | `transformers` |
| RT-DETRv2 | [RT-DETRv2: Improved Baseline with Bag-of-Freebies for Real-Time Detection Transformers](https://arxiv.org/abs/2407.17140) | `transformers` |
diff --git a/assets/data/attention_table2.png b/assets/data/attention_table2.png
new file mode 100644
index 00000000..8ec54478
Binary files /dev/null and b/assets/data/attention_table2.png differ
diff --git a/assets/data/attention_table3.png b/assets/data/attention_table3.png
new file mode 100644
index 00000000..d89dd926
Binary files /dev/null and b/assets/data/attention_table3.png differ
diff --git a/assets/table_transformer_batch_output.jpg b/assets/table_transformer_batch_output.jpg
new file mode 100644
index 00000000..756590dc
Binary files /dev/null and b/assets/table_transformer_batch_output.jpg differ
diff --git a/assets/table_transformer_output.jpg b/assets/table_transformer_output.jpg
new file mode 100644
index 00000000..e34380da
Binary files /dev/null and b/assets/table_transformer_output.jpg differ
diff --git a/docs/classification_backbones.md b/docs/classification_backbones.md
index 249ae8fe..38f07366 100644
--- a/docs/classification_backbones.md
+++ b/docs/classification_backbones.md
@@ -30,7 +30,7 @@ backbone = ResNetModel.from_weights("zeromodels/resnet50_a1_in1k")
feature_map = backbone(images) # (B, H/32, W/32, 2048)
```
-The same pattern works for every classification arch - swap `ResNet` for `CaiT`, `ViT`, `ConvNeXt`, `EfficientNet`, `Swin`, `MobileNetV3`, etc.
+The same pattern works for every classification arch - swap `ResNet` for `CaiT`, `ViT`, `ConvNeXt`, `RegNet`, `EfficientNet`, `Swin`, `MobileNetV3`, etc.
## Data Format
@@ -65,6 +65,7 @@ The number of stages and their semantics depend on the architecture:
| Family | Stages when `as_backbone=True` |
|-----------------------------------------------------|-----------------------------------------------------------------|
| ResNet / ResNetV2 / Res2Net / ResNeXt / SENet | 4 (one per residual stage) |
+| RegNet | 4 (one per stage; strides 4, 8, 16, 32) |
| ConvNeXt / ConvNeXtV2 | 4 |
| EfficientNet / EfficientNet-Lite / EfficientNetV2 | 5 (at stride-2 boundaries; head conv excluded) |
| EfficientFormer | 4 |
diff --git a/docs/regnet.md b/docs/regnet.md
deleted file mode 100644
index 65c7a225..00000000
--- a/docs/regnet.md
+++ /dev/null
@@ -1,165 +0,0 @@
-# RegNet
-
-
-
Weights: pretrained Keras weights live on Hugging Face under
-
zeromodels/regnet-<variant>
-(12
X + 12
Y variants; each repo carries
zm_config.json +
-
model.weights.h5). Load with
-
from_weights("zeromodels/regnet-y-040").
-
-
-RegNet (Designing Network Design Spaces) is a family of ConvNets whose per-stage widths and
-depths follow a simple **quantized-linear rule** found by searching design spaces rather than
-tuning individual architectures. It is a 3x3 stride-2 stem followed by **four stages** of
-residual blocks; each block is a `1x1 -> 3x3 grouped -> 1x1` bottleneck, and the **RegNet-Y**
-variant inserts a **Squeeze-and-Excitation** module. The 3x3 convolution is split into
-`out_channels // groups_width` groups. Width grows and resolution halves each stage, so a
-single backbone yields a standard CNN feature pyramid usable for classification and dense
-prediction.
-
-**Paper**: [Designing Network Design Spaces](https://arxiv.org/abs/2003.13678)
-
-RegNet comes in two families: **X** (plain bottleneck) and **Y** (+ Squeeze-and-Excitation,
-the stronger and more common one).
-
-## API
-
-### RegNetImageClassify
-
-```python
-RegNetImageClassify(
- embedding_size=32,
- hidden_sizes=(128, 192, 512, 1088),
- depths=(2, 6, 12, 2),
- groups_width=64,
- layer_type="y",
- downsample_in_first_stage=True,
- image_size=224,
- include_normalization=True,
- normalization_mode="imagenet",
- num_classes=1000,
- classifier_activation="linear",
- name="RegNetImageClassify",
-)
-```
-
-The classifier: the backbone plus a GlobalAveragePooling2D + dense head.
-`include_normalization=True` means the model takes **raw `[0, 255]` pixels** and applies
-ImageNet mean/std internally, so there is no separate image processor to construct.
-
-**Parameters**
-
-- **embedding_size** (`int`): output width of the 3x3 stride-2 stem.
-- **hidden_sizes** / **depths** (`tuple`): per-stage output width and block count.
-- **groups_width** (`int`): channels per group of the 3x3 grouped convolution (a block's group count is `out_channels // groups_width`).
-- **layer_type** (`str`): `"y"` (adds Squeeze-and-Excitation) or `"x"`.
-- **downsample_in_first_stage** (`bool`): whether the first stage downsamples. `True` for the standard checkpoints (RegNet has no pooling stem).
-- **image_size** (`int`, *optional*, defaults to `224`): resolution the model is built for.
-- **include_normalization** (`bool`, *optional*, defaults to `True`): bake ImageNet normalization into the graph.
-- **num_classes** (`int`, *optional*, defaults to `1000`): classifier outputs.
-
-`from_weights` fills the architectural fields from the variant's config, so you normally pass
-only the repo id.
-
-**Call** `model(pixel_values, training=False)`. **Returns** class logits of shape
-`(B, num_classes)`.
-
-### RegNetModel
-
-The backbone alone. With `as_backbone=True` it returns the four stage feature maps
-(the pyramid) instead of just the last one, for detection or segmentation necks.
-
-```python
-RegNetModel(as_backbone=False, layer_type="y", groups_width=64, ..., include_normalization=True)
-```
-
-### RegNetConfig
-
-Typed config (`model_type="regnet"`) holding the fields above; serialized into each Hub repo's
-`zm_config.json`.
-
-## Model Variants
-
-For `RegNetImageClassify.from_weights("zeromodels/regnet-")`. The number is the
-model's compute in units of 0.1 GFLOPs (`002` = 0.2 GF ... `320` = 32 GF).
-
-| Family | Variants |
-|------------------------|--------------------------------------------------------------------------|
-| **RegNet-X** (plain) | `regnet-x-{002,004,006,008,016,032,040,064,080,120,160,320}` |
-| **RegNet-Y** (+ SE) | `regnet-y-{002,004,006,008,016,032,040,064,080,120,160,320}` |
-
-At matched compute the Y family (with Squeeze-and-Excitation) is generally stronger; e.g.
-`regnet-y-320` reaches ~80.9% ImageNet-1k top-1. All are 224x224, 1000 classes. The larger
-self-supervised `facebook/regnet-y-{320,640,1280,10b}-seer` checkpoints are not mirrored here
-but load on the fly with the `hf:` prefix (see [below](#loading-fine-tuned-and-community-weights)).
-
-## Basic Usage
-
-```python
-import keras
-import numpy as np
-from PIL import Image
-from zeromodels.models.regnet import RegNetImageClassify
-
-model = RegNetImageClassify.from_weights("zeromodels/regnet-y-040")
-
-image = Image.open("assets/data/coco_bear.jpg").convert("RGB").resize((224, 224))
-pixels = np.asarray(image, "float32")[None] # (1, 224, 224, 3), raw [0, 255]
-
-logits = model(pixels, training=False)
-top5 = np.argsort(keras.ops.convert_to_numpy(logits)[0])[-5:][::-1]
-print("top-5 ImageNet-1k class ids:", top5.tolist())
-```
-
-Normalization is inside the model, so pass raw pixels. Map the class ids to the
-[ImageNet-1k label list](https://huggingface.co/datasets/imagenet-1k) to read names.
-
-## Feature Pyramid
-
-For detection / segmentation, take the four stage outputs:
-
-```python
-from zeromodels.models.regnet import RegNetModel
-
-backbone = RegNetModel.from_weights("zeromodels/regnet-y-040", as_backbone=True)
-feats = backbone(np.zeros((1, 224, 224, 3), "float32"), training=False)
-print([tuple(f.shape) for f in feats])
-# spatial 56 / 28 / 14 / 7 (strides 4, 8, 16, 32); channels are the variant's hidden_sizes
-```
-
-The strides are 4, 8, 16, 32, matching a standard CNN backbone. RegNet is fully
-convolutional (no learned position embeddings), so any input resolution works with no
-weight interpolation: build the model at the target `image_size`.
-
-## Data Format
-
-**The model supports both `channels_last` and `channels_first`, and the two are
-bit-exact.** A model reads `keras.config.image_data_format()` when it is **constructed**
-(there is no `data_format` argument); set the format before building.
-
-```python
-import keras
-
-keras.config.set_image_data_format("channels_first")
-model = RegNetImageClassify.from_weights(
- "zeromodels/regnet-y-040"
-) # expects (B, 3, H, W)
-```
-
-## Loading Fine-tuned and Community Weights
-
-The `zeromodels/regnet-*` repos above are pre-converted. Any **other** Hugging Face repo whose
-`model_type` is `"regnet"` (the upstream `facebook/regnet-*` and `-seer` checkpoints, or any
-fine-tune) loads with the `hf:` prefix, converting on the fly:
-
-```python
-from zeromodels.models.regnet import RegNetImageClassify
-
-model = RegNetImageClassify.from_weights("hf:facebook/regnet-x-320")
-model = RegNetImageClassify.from_weights("hf:/regnet-finetuned-on-my-data")
-
-# Architecture only, randomly initialized
-model = RegNetImageClassify.from_weights("hf:facebook/regnet-y-040", load_weights=False)
-```
-
-`RegNetModel` accepts `hf:` the same way.
diff --git a/docs/table_transformer.md b/docs/table_transformer.md
new file mode 100644
index 00000000..2b4a247f
--- /dev/null
+++ b/docs/table_transformer.md
@@ -0,0 +1,374 @@
+# Table Transformer
+
+
+
Weights: pretrained Keras weights live on Hugging Face under
+
zeromodels/table-transformer-<variant>
+(each repo carries
zm_config.json,
zm_preprocessor.json, and
+
model.weights.h5). Load with
+
from_weights("zeromodels/table-transformer-detection"); the architecture is read
+from the repo config, so no shape arguments are needed.
+
+
+Table Transformer (TATR) applies the DETR detection recipe to tables. It is the same
+end-to-end set-prediction model: a ResNet backbone produces a feature map, a transformer
+encoder-decoder attends over it with a fixed set of learned object queries, and each query
+emits one class and one box, with no anchors and no non-maximum suppression. It ships in two
+tasks, both the same architecture with a different query count and label set:
+
+- **Table detection** finds tables in a page image (classes: table, table rotated).
+- **Table-structure recognition** decomposes a cropped table into its cells (classes: table,
+ column, row, column header, projected row header, spanning cell).
+
+Two details separate it from DETR: the encoder and decoder layers are **pre-normalized** (the
+LayerNorm sits before each attention / feed-forward sub-layer) with an extra final encoder
+LayerNorm, and the backbone is a **ResNet-18** (so the 1x1 input projection reduces 512
+channels rather than 2048).
+
+**Paper**: [PubTables-1M: Towards comprehensive table extraction from unstructured
+documents](https://arxiv.org/abs/2110.00061)
+
+## API
+
+### TableTransformerDetect
+
+```python
+TableTransformerDetect(
+ hidden_dim=256,
+ num_heads=8,
+ num_encoder_layers=6,
+ num_decoder_layers=6,
+ dim_feedforward=2048,
+ dropout_rate=0.1,
+ num_queries=15,
+ num_classes=3,
+ image_size=800,
+ input_tensor=None,
+ name="TableTransformerDetect",
+)
+```
+
+The detection model: backbone, transformer, and the class and box heads. **This one class
+serves both tasks**; only `num_queries` and `num_classes` differ between the checkpoints, and
+`from_weights` fills them in from the repo config.
+
+**Parameters**
+
+- **hidden_dim** (`int`, *optional*, defaults to `256`): transformer width, the `d_model` of the HF config.
+- **num_heads** (`int`, *optional*, defaults to `8`): attention heads.
+- **num_encoder_layers** (`int`, *optional*, defaults to `6`): encoder depth.
+- **num_decoder_layers** (`int`, *optional*, defaults to `6`): decoder depth.
+- **dim_feedforward** (`int`, *optional*, defaults to `2048`): FFN inner dimension.
+- **dropout_rate** (`float`, *optional*, defaults to `0.1`): dropout, active only during training.
+- **num_queries** (`int`, *optional*, defaults to `15`): learned object queries, the hard ceiling on detections per image. Detection uses 15, structure recognition 125.
+- **num_classes** (`int`, *optional*, defaults to `3`): object classes plus the "no object" class (detection: 2 + 1; structure: 6 + 1).
+- **image_size** (`int`, *optional*, defaults to `800`): input resolution the model is built for.
+- **input_tensor** (`dict`, *optional*): pre-existing input tensor to build on.
+- **name** (`str`, *optional*, defaults to `"TableTransformerDetect"`): model name.
+
+**Call** `model(pixel_values, training=False)`. **Returns** a `dict`:
+
+- **logits** (`(B, num_queries, num_classes)`): per-query class logits.
+- **pred_boxes** (`(B, num_queries, 4)`): normalized `(cx, cy, w, h)` in `[0, 1]`.
+
+Raw output is one prediction per query, most of them the "no object" class. Run it through
+`post_process_object_detection` to get scored, pixel-space boxes.
+
+### TableTransformerModel
+
+```python
+TableTransformerModel(
+ hidden_dim=256,
+ num_heads=8,
+ num_encoder_layers=6,
+ num_decoder_layers=6,
+ dim_feedforward=2048,
+ dropout_rate=0.1,
+ num_queries=15,
+ image_size=800,
+ input_tensor=None,
+ name="TableTransformerModel",
+)
+```
+
+The backbone and transformer without detection heads, ending at the decoder hidden states.
+Use it when you want Table Transformer features to attach your own head to.
+
+**Parameters** are identical to [TableTransformerDetect](#tabletransformerdetect), minus
+**num_classes**, and **name** defaults to `"TableTransformerModel"`.
+
+**Returns** the decoder's last hidden state, `(B, num_queries, hidden_dim)`.
+
+## Preprocessing
+
+### TableTransformerImageProcessor
+
+```python
+TableTransformerImageProcessor(
+ size=None,
+ resample="bilinear",
+ do_rescale=True,
+ rescale_factor=1 / 255,
+ do_normalize=True,
+ image_mean=None,
+ image_std=None,
+ return_tensor=True,
+ data_format=None,
+)
+```
+
+Resizes to a fixed square, rescales to `[0, 1]`, and normalizes with ImageNet statistics
+(the same preprocessing the Table Transformer checkpoints ship with).
+
+**Parameters**
+
+- **size** (`dict`, *optional*, defaults to `{"height": 800, "width": 800}`): target size.
+- **resample** (`str`, *optional*, defaults to `"bilinear"`): resize interpolation.
+- **do_rescale** (`bool`, *optional*, defaults to `True`): scale pixels to `[0, 1]`.
+- **rescale_factor** (`float`, *optional*, defaults to `1/255`): the rescaling factor.
+- **do_normalize** (`bool`, *optional*, defaults to `True`): apply mean/std normalization.
+- **image_mean** (`tuple`, *optional*, defaults to `(0.485, 0.456, 0.406)`): per-channel mean.
+- **image_std** (`tuple`, *optional*, defaults to `(0.229, 0.224, 0.225)`): per-channel std.
+- **return_tensor** (`bool`, *optional*, defaults to `True`): return backend tensors rather than numpy.
+- **data_format** (`str`, *optional*): `"channels_last"` or `"channels_first"`. Defaults to `keras.config.image_data_format()`.
+
+**Call** `processor(image)` with a path, a PIL image, an array, or a **list** of any mix of
+those. **Returns** a `dict` with **pixel_values** (`(B, H, W, 3)`).
+
+**post_process_object_detection**
+
+```python
+processor.post_process_object_detection(
+ outputs, threshold=0.7, target_sizes=None, label_names=None
+)
+```
+
+Softmaxes the logits, drops the "no object" class, keeps whatever clears `threshold`, and
+converts boxes to pixel-space `(x0, y0, x1, y1)` scaled to `target_sizes`.
+
+- **outputs**: the `dict` returned by the model.
+- **threshold** (`float`, *optional*, defaults to `0.7`): minimum class probability.
+- **target_sizes** (`list` of `(height, width)`, *optional*): original image sizes, one per batch element.
+- **label_names** (`list` of `str`, *optional*): class names. Defaults to the six structure-recognition labels; pass `TABLE_DETECTION_LABELS` for the detection model.
+
+**Returns** a list with one `dict` per image, each holding **scores**, **labels**,
+**label_names**, and **boxes** (`(x0, y0, x1, y1)` in pixels).
+
+The module also exports the two label tuples for convenience:
+
+```python
+from zeromodels.models.table_transformer.table_transformer_image_processor import (
+ TABLE_DETECTION_LABELS, # ("table", "table rotated")
+ TABLE_STRUCTURE_LABELS, # ("table", "table column", "table row", ...)
+)
+```
+
+## Model Variants
+
+All variants are ResNet-18 backboned. Load any of them with
+`TableTransformerDetect.from_weights("zeromodels/")`.
+
+| Task | Repo | Queries | Classes |
+|------------------------|---------------------------------------------------------------|--------:|--------:|
+| Table detection | `zeromodels/table-transformer-detection` | 15 | 2 + 1 |
+| Structure recognition | `zeromodels/table-transformer-structure-recognition` | 125 | 6 + 1 |
+| Structure v1.1 (all) | `zeromodels/table-transformer-structure-recognition-v1.1-all` | 125 | 6 + 1 |
+| Structure v1.1 (fin) | `zeromodels/table-transformer-structure-recognition-v1.1-fin` | 125 | 6 + 1 |
+| Structure v1.1 (pub) | `zeromodels/table-transformer-structure-recognition-v1.1-pub` | 125 | 6 + 1 |
+
+Detection finds tables in a page image; the structure-recognition variants decompose a
+cropped table into its rows, columns, and header / spanning cells (the three v1.1 checkpoints
+differ only in their training corpus: all, financial, or publication tables).
+
+## Basic Usage: Table Detection
+
+```python
+from PIL import Image
+from zeromodels.models.table_transformer import (
+ TableTransformerDetect,
+ TableTransformerImageProcessor,
+)
+from zeromodels.models.table_transformer.table_transformer_image_processor import (
+ TABLE_DETECTION_LABELS,
+)
+
+model = TableTransformerDetect.from_weights("zeromodels/table-transformer-detection")
+processor = TableTransformerImageProcessor.from_weights(
+ "zeromodels/table-transformer-detection"
+)
+
+image = Image.open("page.jpg").convert("RGB")
+inputs = processor(image)
+
+output = model(inputs["pixel_values"], training=False)
+# output["logits"]: (1, 15, 3)
+# output["pred_boxes"]: (1, 15, 4)
+
+results = processor.post_process_object_detection(
+ output,
+ threshold=0.9,
+ target_sizes=[(image.height, image.width)],
+ label_names=TABLE_DETECTION_LABELS,
+)[0]
+
+for score, name, box in sorted(
+ zip(results["scores"], results["label_names"], results["boxes"]),
+ key=lambda d: -float(d[0]),
+):
+ print(f"{name:14s} {float(score):.3f} {[round(float(v)) for v in box]}")
+```
+
+Each kept detection is a table region in pixel coordinates. Crop the page to those boxes to
+get the table images for the next stage.
+
+## Table-Structure Recognition
+
+Structure recognition takes a **cropped table** and predicts its **structure** (six classes:
+rows, columns, the column header, projected row headers, and spanning cells), not one box per
+cell. That is the difference from cell-detection models such as TableFormer: to get discrete
+cells you **intersect the predicted rows and columns** (and keep each spanning cell whole).
+The tables below are the real Table 2 and Table 3 from *Attention Is All You Need*
+(arXiv:1706.03762), cropped from the paper's PDF pages with the `table-transformer-detection`
+checkpoint. Here the ablations table is decomposed into its cells (green), with the
+A / B / C / D variation-group labels kept as single spanning cells (red):
+
+
+
+```python
+from PIL import Image
+from zeromodels.models.table_transformer import (
+ TableTransformerDetect,
+ TableTransformerImageProcessor,
+)
+
+model = TableTransformerDetect.from_weights(
+ "zeromodels/table-transformer-structure-recognition-v1.1-all"
+)
+processor = TableTransformerImageProcessor.from_weights(
+ "zeromodels/table-transformer-structure-recognition-v1.1-all"
+)
+
+table = Image.open("assets/data/attention_table3.png").convert("RGB")
+output = model(processor(table)["pixel_values"], training=False)
+# output["logits"]: (1, 125, 7) six structure classes + no-object
+# output["pred_boxes"]: (1, 125, 4)
+
+struct = processor.post_process_object_detection(
+ output, threshold=0.6, target_sizes=[(table.height, table.width)]
+)[0]
+
+
+def structure_to_cells(struct):
+ """One box per cell, from the predicted rows x columns (spanning cells kept whole)."""
+
+ def kind(name):
+ return [
+ [float(v) for v in box]
+ for box, n in zip(struct["boxes"], struct["label_names"])
+ if n == name
+ ]
+
+ rows = sorted(kind("table row"), key=lambda b: b[1])
+ cols = sorted(kind("table column"), key=lambda b: b[0])
+ spans = kind("table spanning cell")
+
+ def spanned(box):
+ cx, cy = (box[0] + box[2]) / 2, (box[1] + box[3]) / 2
+ return any(s[0] <= cx <= s[2] and s[1] <= cy <= s[3] for s in spans)
+
+ grid = [[c[0], r[1], c[2], r[3]] for r in rows for c in cols]
+ return [b for b in grid if not spanned(b)] + spans
+
+
+cells = structure_to_cells(struct)
+print(f"{len(cells)} cells")
+```
+
+```
+260 cells
+```
+
+The raw prediction has **13** `table column`, **21** `table row`, **1** `table column header`,
+and **4** `table spanning cell` boxes (read them off `struct["label_names"]`). Intersecting the
+13 columns with the 21 rows gives a 273-cell grid; dropping the 17 grid cells covered by the
+four A / B / C / D spanning cells and adding those 4 spans back yields the **260** cells drawn
+above. The column-header and spanning-cell classes then tell you which cells are headers or
+span multiple rows / columns.
+
+### Batch of Multiple Tables
+
+Pass a list of table crops and one `target_sizes` entry per image. Every image is resized to
+the same square, so stacking is always safe and the batch result is identical to running the
+images one at a time. Here the paper's results table (Table 2) and ablations table (Table 3)
+are decomposed into cells together (reusing `structure_to_cells` from above):
+
+
+
+```python
+from PIL import Image
+
+paths = ["assets/data/attention_table2.png", "assets/data/attention_table3.png"]
+images = [Image.open(p).convert("RGB") for p in paths]
+
+inputs = processor(paths) # (2, 800, 800, 3)
+outputs = model(inputs["pixel_values"], training=False)
+
+results = processor.post_process_object_detection(
+ outputs, threshold=0.6, target_sizes=[(im.height, im.width) for im in images]
+)
+for path, struct in zip(paths, results):
+ print(path, len(structure_to_cells(struct)), "cells")
+```
+
+```
+assets/data/attention_table2.png 59 cells
+assets/data/attention_table3.png 260 cells
+```
+
+The results table (5 x 12) and the ablations table (13 x 21) are decomposed together, and each
+image's cells match running it on its own.
+
+## Data Format
+
+**Both the model and the processor support `channels_last` and `channels_first`.** They pick
+the format differently:
+
+| | How it picks the format |
+|---|---|
+| Processor | A `data_format` kwarg, per instance. `None` (the default) resolves to `keras.config.image_data_format()`. |
+| Model | Reads `keras.config.image_data_format()` when it is **constructed**. There is no `data_format` argument. |
+
+Set the global format before constructing the model, and both sides agree:
+
+```python
+import keras
+
+keras.config.set_image_data_format("channels_first")
+model = TableTransformerDetect.from_weights("zeromodels/table-transformer-detection")
+# now expects (B, 3, H, W)
+```
+
+Detections are identical under either layout; only the tensor shape changes. The
+post-processor emits `xyxy` pixel boxes and class indices, which have no channel axis, so it
+takes no `data_format` kwarg.
+
+## Loading Fine-tuned and Community Weights
+
+The `zeromodels/table-transformer-*` repos above are pre-converted. Any **other** Hugging Face
+repo whose `model_type` is `"table-transformer"` (the upstream `microsoft/table-transformer-*`
+checkpoints, or a community fine-tune) loads with the `hf:` prefix, converting on the fly:
+
+```python
+from zeromodels.models.table_transformer import TableTransformerDetect
+
+model = TableTransformerDetect.from_weights("hf:/my-table-transformer-finetune")
+
+# Architecture only, randomly initialized
+model = TableTransformerDetect.from_weights(
+ "zeromodels/table-transformer-detection", load_weights=False
+)
+```
+
+No shape arguments are needed. The architecture is read from the repo's `config.json` and
+mapped onto the constructor: `d_model`, `encoder_attention_heads`, `encoder_layers`,
+`decoder_layers`, `encoder_ffn_dim`, `num_queries`, and the label count. `TableTransformerModel`
+loads the same way, warm-starting the backbone and transformer from the detector's weights.
diff --git a/tests/base/model_test_registry.py b/tests/base/model_test_registry.py
index 46d19d47..c6da372c 100644
--- a/tests/base/model_test_registry.py
+++ b/tests/base/model_test_registry.py
@@ -745,6 +745,21 @@
"pred_masks": (2, 10, 8, 8),
},
},
+ "TableTransformerDetect": {
+ "module": "zeromodels.models.table_transformer",
+ "model_cls": "TableTransformerDetect",
+ "model_type": "object_detection",
+ "init_kwargs": {
+ "image_size": 32,
+ "num_classes": 3,
+ "num_queries": 10,
+ },
+ "input_shape": (2, 32, 32, 3),
+ "expected_output_shape": {
+ "logits": (2, 10, 3),
+ "pred_boxes": (2, 10, 4),
+ },
+ },
"RTDETRDetect": {
"module": "zeromodels.models.rt_detr",
"model_cls": "RTDETRDetect",
diff --git a/website/mkdocs.yml b/website/mkdocs.yml
index d37d9d36..fa8dc5a6 100644
--- a/website/mkdocs.yml
+++ b/website/mkdocs.yml
@@ -184,13 +184,13 @@ nav:
- MobileViTV2: mobilevitv2.md
- PVT: pvt.md
- PVTv2: pvt_v2.md
- - RegNet: regnet.md
- RF-DETR: rf_detr.md
- RT-DETR: rt_detr.md
- RT-DETRv2: rt_detr_v2.md
- SAM: sam.md
- SAM 2: sam2.md
- SegFormer: segformer.md
+ - Table Transformer: table_transformer.md
- TIPSv2-DPT: tipsv2_dpt.md
- Audio models:
- Granite Speech: granite_speech.md
diff --git a/zeromodels/models/__init__.py b/zeromodels/models/__init__.py
index 4d3ca1ed..e6b88ae7 100644
--- a/zeromodels/models/__init__.py
+++ b/zeromodels/models/__init__.py
@@ -121,6 +121,7 @@
swin,
swinv2,
t5,
+ table_transformer,
tipsv2,
tipsv2_dpt,
vgg,
diff --git a/zeromodels/models/regnet/convert_regnet_hf_to_keras.py b/zeromodels/models/regnet/convert_regnet_hf_to_keras.py
index 7c98a097..53dd38fd 100644
--- a/zeromodels/models/regnet/convert_regnet_hf_to_keras.py
+++ b/zeromodels/models/regnet/convert_regnet_hf_to_keras.py
@@ -17,11 +17,6 @@
)
from zeromodels.models.regnet import RegNetImageClassify
-# Hosted variant -> HF (transformers) repo id, for every standard RegNet X/Y FLOP
-# variant (002=0.2 GF ... 320=32 GF). The arch is read from each repo's config.json
-# via RegNetImageClassify.config_from_hf, so no per-variant arch table is needed
-# here; ``hf:facebook/regnet-*`` also loads any of these on the fly. (The larger
-# self-supervised "-seer" checkpoints are not listed but load the same way.)
REGNET_FLOPS = (
"002",
"004",
@@ -42,9 +37,6 @@
for flops in REGNET_FLOPS
}
-# keras weight name ``{layer.name}_{weight.name}`` -> HF (torch) name. The keras
-# layers are named as the HF module path with ``.`` replaced by ``_``, so ``_ ->
-# .`` alone reconstructs the path; the rest renames Keras weight suffixes.
WEIGHT_NAME_MAPPING = {
"_": ".",
"kernel": "weight",
@@ -85,16 +77,9 @@ def transfer_regnet_weights(keras_model, state_dict):
_meta.version = lambda name: (
"0.23.0" if name == "tokenizers" else _orig_version(name)
)
- import torch
import transformers
from huggingface_hub import hf_hub_download
- # Compare in true float32: cuDNN TF32 on a GPU inflates the conv/BN diff to
- # ~1e-2 (HF runs on CPU), which can spuriously trip the 1e-2 parity gate on the
- # SE ("y") variants. Disabling TF32 restores the real ~5e-6 conversion fidelity.
- torch.backends.cudnn.allow_tf32 = False
- torch.backends.cuda.matmul.allow_tf32 = False
-
for variant, hf_id in REGNET_VARIANTS.items():
print(f"\n{'=' * 60}")
print(f"Converting: {variant} <- {hf_id}")
diff --git a/zeromodels/models/rf_detr/rf_detr_image_processor.py b/zeromodels/models/rf_detr/rf_detr_image_processor.py
index 0002bd76..a9946c6f 100644
--- a/zeromodels/models/rf_detr/rf_detr_image_processor.py
+++ b/zeromodels/models/rf_detr/rf_detr_image_processor.py
@@ -1,8 +1,7 @@
-from typing import Dict, List, Optional, Tuple, Union
+from typing import Dict, List, Optional, Tuple
import keras
-import numpy as np
-from PIL import Image
+from keras import ops
from zeromodels.base import BaseImageProcessor
from zeromodels.utils.labels_util import COCO_91_CLASSES
@@ -86,14 +85,10 @@ def variant_size(variant: Optional[str]) -> Dict[str, int]:
"""
return {"height": 560, "width": 560}
- def __call__(
- self, image: Union[str, np.ndarray, Image.Image, List]
- ) -> Dict[str, Union[keras.KerasTensor, np.ndarray]]:
+ def __call__(self, image) -> Dict[str, keras.KerasTensor]:
return self.call(image)
- def call(
- self, image: Union[str, np.ndarray, Image.Image, List]
- ) -> Dict[str, Union[keras.KerasTensor, np.ndarray]]:
+ def call(self, image) -> Dict[str, keras.KerasTensor]:
if isinstance(image, (list, tuple)):
return self.stack_images(image)
image, _, _, _ = self.preprocess_image(
@@ -110,7 +105,7 @@ def call(
image = image * (self.rescale_factor * 255)
if not self.return_tensor:
- image = keras.ops.convert_to_numpy(image)
+ image = ops.convert_to_numpy(image)
return {"pixel_values": image}
@@ -155,7 +150,7 @@ def rf_detr_post_process_object_detection(
num_top_queries: int = 300,
target_sizes: Optional[List[Tuple[int, int]]] = None,
label_names: Optional[List[str]] = None,
-) -> List[Dict[str, np.ndarray]]:
+) -> list:
"""Post-process raw RF-DETR outputs into usable detections.
RF-DETR uses sigmoid activation (not softmax) and does not have a
@@ -199,34 +194,29 @@ def rf_detr_post_process_object_detection(
print(f"{name}: {score:.2f}")
```
"""
- logits = keras.ops.convert_to_numpy(outputs["logits"])
- boxes = keras.ops.convert_to_numpy(outputs["pred_boxes"])
+ logits = ops.convert_to_tensor(outputs["logits"])
+ boxes = ops.convert_to_tensor(outputs["pred_boxes"])
batch_size = logits.shape[0]
num_classes = logits.shape[2]
- probs = sigmoid(logits)
+ probs = ops.sigmoid(logits)
results = []
for i in range(batch_size):
- prob_i = probs[i]
- boxes_i = boxes[i]
-
- flat_scores = prob_i.reshape(-1)
+ flat_scores = ops.reshape(probs[i], (-1,))
num_select = min(num_top_queries, flat_scores.shape[0])
- topk_indices = np.argpartition(flat_scores, -num_select)[-num_select:]
- topk_indices = topk_indices[np.argsort(-flat_scores[topk_indices])]
-
- topk_scores = flat_scores[topk_indices]
- topk_box_indices = topk_indices // num_classes
- topk_labels = topk_indices % num_classes
+ topk_scores, topk_indices = ops.top_k(flat_scores, num_select)
- topk_boxes = boxes_i[topk_box_indices]
+ topk_box_indices = ops.floor_divide(topk_indices, num_classes)
+ topk_labels = ops.mod(topk_indices, num_classes)
- keep = topk_scores > threshold
- scores = topk_scores[keep]
- labels = topk_labels[keep]
- kept_boxes = topk_boxes[keep]
+ keep = ops.nonzero(ops.greater(topk_scores, threshold))[0]
+ scores = ops.take(topk_scores, keep, axis=0)
+ labels = ops.take(topk_labels, keep, axis=0)
+ kept_boxes = ops.take(
+ boxes[i], ops.take(topk_box_indices, keep, axis=0), axis=0
+ )
cx, cy, w, h = (
kept_boxes[:, 0],
@@ -234,19 +224,23 @@ def rf_detr_post_process_object_detection(
kept_boxes[:, 2],
kept_boxes[:, 3],
)
- x_min = cx - w / 2
- y_min = cy - h / 2
- x_max = cx + w / 2
- y_max = cy + h / 2
- xyxy_boxes = np.stack([x_min, y_min, x_max, y_max], axis=-1)
+ xyxy_boxes = ops.stack(
+ [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=-1
+ )
if target_sizes is not None:
img_h, img_w = target_sizes[i]
- scale = np.array([img_w, img_h, img_w, img_h], dtype=np.float32)
+ scale = ops.convert_to_tensor([img_w, img_h, img_w, img_h], dtype="float32")
xyxy_boxes = xyxy_boxes * scale
- _names = label_names if label_names is not None else COCO_91_CLASSES
- mapped_names = [_names[l] if l < len(_names) else f"class_{l}" for l in labels]
+ scores = ops.convert_to_numpy(scores)
+ labels = ops.convert_to_numpy(labels)
+ xyxy_boxes = ops.convert_to_numpy(xyxy_boxes)
+
+ names = label_names if label_names is not None else COCO_91_CLASSES
+ mapped_names = [
+ names[label] if label < len(names) else f"class_{label}" for label in labels
+ ]
results.append(
{
@@ -267,7 +261,7 @@ def rf_detr_post_process_instance_segmentation(
target_sizes: Optional[List[Tuple[int, int]]] = None,
label_names: Optional[List[str]] = None,
mask_threshold: float = 0.5,
-) -> List[Dict[str, np.ndarray]]:
+) -> list:
"""Post-process ``RFDETRInstanceSegment`` outputs into instance masks + scores/labels/boxes.
Mirrors :func:`rf_detr_post_process_object_detection`'s sigmoid + flat top-k
@@ -294,78 +288,81 @@ def rf_detr_post_process_instance_segmentation(
``"label_names"``, ``"boxes"`` (xyxy), and ``"masks"``: a boolean array of
shape ``(K, H, W)`` for each image.
"""
- logits = keras.ops.convert_to_numpy(outputs["logits"])
- boxes = keras.ops.convert_to_numpy(outputs["pred_boxes"])
- mask_logits = keras.ops.convert_to_numpy(outputs["pred_masks"]).astype(np.float32)
+ logits = ops.convert_to_tensor(outputs["logits"])
+ boxes = ops.convert_to_tensor(outputs["pred_boxes"])
+ mask_logits = ops.cast(ops.convert_to_tensor(outputs["pred_masks"]), "float32")
batch_size = logits.shape[0]
num_classes = logits.shape[2]
- probs = sigmoid(logits)
+ probs = ops.sigmoid(logits)
results = []
for i in range(batch_size):
- prob_i = probs[i]
- boxes_i = boxes[i]
- masks_i = mask_logits[i]
-
- flat_scores = prob_i.reshape(-1)
+ flat_scores = ops.reshape(probs[i], (-1,))
num_select = min(num_top_queries, flat_scores.shape[0])
- topk_indices = np.argpartition(flat_scores, -num_select)[-num_select:]
- topk_indices = topk_indices[np.argsort(-flat_scores[topk_indices])]
-
- topk_scores = flat_scores[topk_indices]
- topk_query_indices = topk_indices // num_classes
- topk_labels = topk_indices % num_classes
-
- keep = topk_scores > threshold
- q_idx = topk_query_indices[keep]
- labels = topk_labels[keep]
- scores = topk_scores[keep]
-
- seen, sel = set(), []
- for j in range(len(q_idx)):
- if int(q_idx[j]) not in seen:
- seen.add(int(q_idx[j]))
- sel.append(j)
- q_idx = q_idx[sel]
- labels = labels[sel]
- scores = scores[sel]
-
- kept_boxes = boxes_i[q_idx]
+ topk_scores, topk_indices = ops.top_k(flat_scores, num_select)
+ topk_query = ops.floor_divide(topk_indices, num_classes)
+ topk_labels = ops.mod(topk_indices, num_classes)
+
+ # Keep the above-threshold pairs, then, since the scores are sorted
+ # descending, keep only the first (highest-scoring) occurrence of each
+ # query: a vectorized dedup via a strictly-lower-triangular equality count.
+ above = ops.greater(topk_scores, threshold)
+ n = topk_query.shape[0]
+ eq = ops.equal(ops.expand_dims(topk_query, 1), ops.expand_dims(topk_query, 0))
+ earlier = ops.tril(ops.ones((n, n)), k=-1)
+ prior = ops.sum(ops.cast(eq, "int32") * ops.cast(earlier, "int32"), axis=1)
+ first = ops.equal(prior, 0)
+ keep = ops.nonzero(ops.logical_and(above, first))[0]
+
+ q_idx = ops.take(topk_query, keep, axis=0)
+ labels = ops.take(topk_labels, keep, axis=0)
+ scores = ops.take(topk_scores, keep, axis=0)
+
+ kept_boxes = ops.take(boxes[i], q_idx, axis=0)
cx, cy, w, h = (
kept_boxes[:, 0],
kept_boxes[:, 1],
kept_boxes[:, 2],
kept_boxes[:, 3],
)
- xyxy_boxes = np.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=-1)
+ xyxy_boxes = ops.stack(
+ [cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=-1
+ )
if target_sizes is not None:
img_h, img_w = target_sizes[i]
- xyxy_boxes = xyxy_boxes * np.array(
- [img_w, img_h, img_w, img_h], dtype=np.float32
- )
+ scale = ops.convert_to_tensor([img_w, img_h, img_w, img_h], dtype="float32")
+ xyxy_boxes = xyxy_boxes * scale
- if q_idx.size > 0:
- km = masks_i[q_idx][..., None] # (K, mh, mw, 1)
+ num_kept = int(keep.shape[0])
+ if num_kept > 0:
+ km = ops.expand_dims(ops.take(mask_logits[i], q_idx, axis=0), -1)
if target_sizes is not None:
img_h, img_w = target_sizes[i]
- km = keras.ops.convert_to_numpy(
- keras.ops.image.resize(
- km,
- (img_h, img_w),
- interpolation="bilinear",
- data_format="channels_last",
- )
+ km = ops.image.resize(
+ km,
+ (img_h, img_w),
+ interpolation="bilinear",
+ data_format="channels_last",
)
- km = km[..., 0]
- masks_bin = (1.0 / (1.0 + np.exp(-km))) > mask_threshold
+ masks_bin = ops.greater(ops.sigmoid(km[..., 0]), mask_threshold)
else:
- mh, mw = target_sizes[i] if target_sizes is not None else masks_i.shape[1:]
- masks_bin = np.zeros((0, mh, mw), dtype=bool)
+ if target_sizes is not None:
+ mh, mw = target_sizes[i]
+ else:
+ mh, mw = mask_logits.shape[2], mask_logits.shape[3]
+ masks_bin = ops.zeros((0, mh, mw), dtype="bool")
- _names = label_names if label_names is not None else COCO_91_CLASSES
- mapped_names = [_names[l] if l < len(_names) else f"class_{l}" for l in labels]
+ scores = ops.convert_to_numpy(scores)
+ labels = ops.convert_to_numpy(labels)
+ xyxy_boxes = ops.convert_to_numpy(xyxy_boxes)
+ masks_bin = ops.convert_to_numpy(masks_bin)
+
+ names = label_names if label_names is not None else COCO_91_CLASSES
+ mapped_names = [
+ names[label] if label < len(names) else f"class_{label}" for label in labels
+ ]
results.append(
{
@@ -378,12 +375,3 @@ def rf_detr_post_process_instance_segmentation(
)
return results
-
-
-def sigmoid(x: np.ndarray) -> np.ndarray:
- """Numerically stable sigmoid."""
- return np.where(
- x >= 0,
- 1.0 / (1.0 + np.exp(-x)),
- np.exp(x) / (1.0 + np.exp(x)),
- )
diff --git a/zeromodels/models/table_transformer/__init__.py b/zeromodels/models/table_transformer/__init__.py
new file mode 100644
index 00000000..da31c6c4
--- /dev/null
+++ b/zeromodels/models/table_transformer/__init__.py
@@ -0,0 +1,10 @@
+from .table_transformer_config import TableTransformerConfig
+from .table_transformer_image_processor import TableTransformerImageProcessor
+from .table_transformer_model import TableTransformerDetect, TableTransformerModel
+
+__all__ = [
+ "TableTransformerConfig",
+ "TableTransformerModel",
+ "TableTransformerDetect",
+ "TableTransformerImageProcessor",
+]
diff --git a/zeromodels/models/table_transformer/convert_table_transformer_hf_to_keras.py b/zeromodels/models/table_transformer/convert_table_transformer_hf_to_keras.py
new file mode 100644
index 00000000..ed8f58f7
--- /dev/null
+++ b/zeromodels/models/table_transformer/convert_table_transformer_hf_to_keras.py
@@ -0,0 +1,317 @@
+import re
+from typing import Dict
+
+import keras
+import numpy as np
+from tqdm import tqdm
+
+from zeromodels.conversion.exceptions import (
+ WeightMappingError,
+ WeightShapeMismatchError,
+)
+from zeromodels.conversion.weight_transfer_util import (
+ compare_keras_torch_names,
+ transfer_nested_layer_weights,
+ transfer_weights,
+)
+from zeromodels.models.table_transformer import TableTransformerDetect
+
+WEIGHT_NAME_MAPPING: Dict[str, str] = {
+ "backbone_layer": "model.backbone.model.layer",
+ "_": ".",
+ "downsample.conv": "downsample.0",
+ "downsample.bn": "downsample.1",
+ "backbone.conv1": "model.backbone.model.conv1",
+ "backbone.bn1": "model.backbone.model.bn1",
+ "kernel": "weight",
+ "gamma": "weight",
+ "beta": "bias",
+ "moving.mean": "running_mean",
+ "moving.variance": "running_var",
+}
+
+
+def transfer_table_transformer_weights(keras_model, state_dict):
+ state_dict = {
+ k.replace("model.backbone.conv_encoder.model.", "model.backbone.model."): v
+ for k, v in state_dict.items()
+ }
+
+ is_timm = "model.backbone.model.conv1.weight" in state_dict
+
+ backbone_layers = [
+ layer for layer in keras_model.layers if layer.name.startswith("backbone_")
+ ]
+ backbone_weights = []
+ for layer in backbone_layers:
+ for weight in layer.trainable_weights + layer.non_trainable_weights:
+ backbone_weights.append((weight, layer.name, weight.name))
+
+ for keras_weight, layer_name, weight_name in tqdm(
+ backbone_weights, desc="Transferring backbone weights"
+ ):
+ keras_weight_name = f"{layer_name}_{weight_name}"
+ # timm layout rewrites the flattened name; HF-native builds the path per module.
+ if is_timm:
+ torch_weight_name = keras_weight_name
+ for old, new in WEIGHT_NAME_MAPPING.items():
+ torch_weight_name = torch_weight_name.replace(old, new)
+ else:
+ sub = "convolution" if "conv" in layer_name else "normalization"
+ suffix = WEIGHT_NAME_MAPPING[weight_name.replace("_", ".")]
+ if layer_name in ("backbone_conv1", "backbone_bn1"):
+ torch_weight_name = (
+ f"model.backbone.model.embedder.embedder.{sub}.{suffix}"
+ )
+ else:
+ match = re.match(r"backbone_layer(\d+)_(\d+)_(.+)", layer_name)
+ stage = int(match.group(1)) - 1
+ block = int(match.group(2))
+ tail = match.group(3)
+ if tail in ("conv1", "bn1"):
+ path = f"encoder.stages.{stage}.layers.{block}.layer.0.{sub}"
+ elif tail in ("conv2", "bn2"):
+ path = f"encoder.stages.{stage}.layers.{block}.layer.1.{sub}"
+ else: # downsample_conv / downsample_bn
+ path = f"encoder.stages.{stage}.layers.{block}.shortcut.{sub}"
+ torch_weight_name = f"model.backbone.model.{path}.{suffix}"
+
+ if torch_weight_name not in state_dict:
+ raise WeightMappingError(keras_weight_name, torch_weight_name)
+
+ torch_weight = state_dict[torch_weight_name]
+ if not compare_keras_torch_names(
+ keras_weight_name, keras_weight, torch_weight_name, torch_weight
+ ):
+ raise WeightShapeMismatchError(
+ keras_weight_name,
+ keras_weight.shape,
+ torch_weight_name,
+ torch_weight.shape,
+ )
+ transfer_weights(keras_weight_name, keras_weight, torch_weight)
+
+ input_proj = keras_model.get_layer("input_projection")
+ conv_w = state_dict["model.input_projection.weight"]
+ input_proj.weights[0].assign(np.transpose(conv_w, (2, 3, 1, 0)))
+ input_proj.weights[1].assign(state_dict["model.input_projection.bias"])
+
+ query_layer = keras_model.get_layer("query_position_embeddings")
+ query_layer.weights[0].assign(state_dict["model.query_position_embeddings.weight"])
+
+ ln_mapping = {"gamma": "weight", "beta": "bias"}
+ dense_mapping = {"kernel": "weight"}
+
+ for i in tqdm(
+ range(keras_model.num_encoder_layers), desc="Transferring encoder weights"
+ ):
+ hf_prefix = f"model.encoder.layers.{i}"
+ k_prefix = f"encoder_layers_{i}"
+
+ sa_mapping = {f"{k_prefix}_self_attn_": "", "kernel": "weight"}
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_self_attn"),
+ state_dict,
+ f"{hf_prefix}.self_attn",
+ name_mapping=sa_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_self_attn_layer_norm"),
+ state_dict,
+ f"{hf_prefix}.self_attn_layer_norm",
+ name_mapping=ln_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_fc1"),
+ state_dict,
+ f"{hf_prefix}.fc1",
+ name_mapping=dense_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_fc2"),
+ state_dict,
+ f"{hf_prefix}.fc2",
+ name_mapping=dense_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_final_layer_norm"),
+ state_dict,
+ f"{hf_prefix}.final_layer_norm",
+ name_mapping=ln_mapping,
+ )
+
+ transfer_nested_layer_weights(
+ keras_model.get_layer("encoder_layernorm"),
+ state_dict,
+ "model.encoder.layernorm",
+ name_mapping=ln_mapping,
+ )
+
+ for i in tqdm(
+ range(keras_model.num_decoder_layers), desc="Transferring decoder weights"
+ ):
+ hf_prefix = f"model.decoder.layers.{i}"
+ k_prefix = f"decoder_layers_{i}"
+
+ sa_mapping = {f"{k_prefix}_self_attn_": "", "kernel": "weight"}
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_self_attn"),
+ state_dict,
+ f"{hf_prefix}.self_attn",
+ name_mapping=sa_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_self_attn_layer_norm"),
+ state_dict,
+ f"{hf_prefix}.self_attn_layer_norm",
+ name_mapping=ln_mapping,
+ )
+ ca_mapping = {f"{k_prefix}_encoder_attn_": "", "kernel": "weight"}
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_encoder_attn"),
+ state_dict,
+ f"{hf_prefix}.encoder_attn",
+ name_mapping=ca_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_encoder_attn_layer_norm"),
+ state_dict,
+ f"{hf_prefix}.encoder_attn_layer_norm",
+ name_mapping=ln_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_fc1"),
+ state_dict,
+ f"{hf_prefix}.fc1",
+ name_mapping=dense_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_fc2"),
+ state_dict,
+ f"{hf_prefix}.fc2",
+ name_mapping=dense_mapping,
+ )
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"{k_prefix}_final_layer_norm"),
+ state_dict,
+ f"{hf_prefix}.final_layer_norm",
+ name_mapping=ln_mapping,
+ )
+
+ transfer_nested_layer_weights(
+ keras_model.get_layer("decoder_layernorm"),
+ state_dict,
+ "model.decoder.layernorm",
+ name_mapping=ln_mapping,
+ )
+
+ transfer_nested_layer_weights(
+ keras_model.get_layer("class_labels_classifier"),
+ state_dict,
+ "class_labels_classifier",
+ name_mapping=dense_mapping,
+ )
+
+ for layer_idx in range(3):
+ transfer_nested_layer_weights(
+ keras_model.get_layer(f"bbox_predictor_{layer_idx}"),
+ state_dict,
+ f"bbox_predictor.layers.{layer_idx}",
+ name_mapping=dense_mapping,
+ )
+
+
+TABLE_TRANSFORMER_VARIANTS = {
+ "table-transformer-detection": "microsoft/table-transformer-detection",
+ "table-transformer-structure-recognition": (
+ "microsoft/table-transformer-structure-recognition"
+ ),
+ "table-transformer-structure-recognition-v1.1-all": (
+ "microsoft/table-transformer-structure-recognition-v1.1-all"
+ ),
+ "table-transformer-structure-recognition-v1.1-fin": (
+ "microsoft/table-transformer-structure-recognition-v1.1-fin"
+ ),
+ "table-transformer-structure-recognition-v1.1-pub": (
+ "microsoft/table-transformer-structure-recognition-v1.1-pub"
+ ),
+}
+
+
+def load_hf_model(hf_id, raw_config):
+ import transformers
+
+ if raw_config.get("dilation") is None:
+ cfg = transformers.TableTransformerConfig.from_dict(
+ {**raw_config, "dilation": False}
+ )
+ return transformers.TableTransformerForObjectDetection.from_pretrained(
+ hf_id, config=cfg
+ ).eval()
+ return transformers.TableTransformerForObjectDetection.from_pretrained(hf_id).eval()
+
+
+if __name__ == "__main__":
+ import gc
+ import json
+
+ import torch
+ from huggingface_hub import hf_hub_download
+
+ from zeromodels.conversion.hf_download_utils import download_hf_state_dict
+
+ for variant, hf_id in TABLE_TRANSFORMER_VARIANTS.items():
+ print(f"\n{'=' * 60}")
+ print(f"Converting: {variant} <- {hf_id}")
+ print(f"{'=' * 60}")
+
+ with open(hf_hub_download(hf_id, "config.json"), encoding="utf-8") as f:
+ hf_config = json.load(f)
+
+ keras_model = TableTransformerDetect(
+ **TableTransformerDetect.config_from_hf(hf_config),
+ image_size=800,
+ )
+ state = download_hf_state_dict(hf_id)
+ transfer_table_transformer_weights(keras_model, state)
+
+ hf_model = load_hf_model(hf_id, hf_config)
+
+ np.random.seed(0)
+ test_input = np.random.rand(1, 800, 800, 3).astype(np.float32)
+ mean = np.array([0.485, 0.456, 0.406]).reshape(1, 1, 1, 3)
+ std = np.array([0.229, 0.224, 0.225]).reshape(1, 1, 1, 3)
+ keras_input = ((test_input - mean) / std).astype(np.float32)
+
+ hf_input = torch.tensor(keras_input).permute(0, 3, 1, 2)
+ with torch.no_grad():
+ hf_out = hf_model(pixel_values=hf_input)
+ hf_logits = hf_out.logits.numpy()
+ hf_boxes = hf_out.pred_boxes.numpy()
+
+ keras_out = keras_model(keras_input, training=False)
+ keras_logits = keras.ops.convert_to_numpy(keras_out["logits"])
+ keras_boxes = keras.ops.convert_to_numpy(keras_out["pred_boxes"])
+
+ logits_diff = float(np.max(np.abs(hf_logits - keras_logits)))
+ boxes_diff = float(np.max(np.abs(hf_boxes - keras_boxes)))
+ cos = float(
+ np.dot(hf_logits.ravel(), keras_logits.ravel())
+ / (np.linalg.norm(hf_logits.ravel()) * np.linalg.norm(keras_logits.ravel()))
+ )
+ print(f" logits max|diff|={logits_diff:.3e} cosine={cos:.8f}")
+ print(f" boxes max|diff|={boxes_diff:.3e}")
+
+ if logits_diff > 1e-3 or boxes_diff > 1e-3:
+ raise ValueError(
+ f"Parity failed for {variant} "
+ f"(logits {logits_diff:.3e}, boxes {boxes_diff:.3e})"
+ )
+
+ out_path = f"{variant}.weights.h5"
+ keras_model.save_weights(out_path)
+ print(f" Saved -> {out_path}")
+
+ del keras_model, hf_model, state
+ keras.backend.clear_session()
+ gc.collect()
diff --git a/zeromodels/models/table_transformer/table_transformer_config.py b/zeromodels/models/table_transformer/table_transformer_config.py
new file mode 100644
index 00000000..ea9075f0
--- /dev/null
+++ b/zeromodels/models/table_transformer/table_transformer_config.py
@@ -0,0 +1,69 @@
+from zeromodels.base import BaseConfig
+
+
+class TableTransformerConfig(BaseConfig):
+ r"""Configuration for [`TableTransformerDetect`], the Table Transformer detector.
+
+ Table Transformer (TATR) is the DETR architecture applied to table
+ detection and table-structure recognition (from Microsoft's PubTables-1M).
+ It reuses DETR's ResNet backbone plus transformer encoder / decoder, with
+ two differences: the encoder and decoder layers are pre-normalized (the
+ LayerNorm is applied before each attention / feed-forward sub-layer) and
+ the encoder has an extra final LayerNorm. The backbone is a ResNet-18
+ (basic blocks, 512-channel last stage), so the 1x1 input projection reduces
+ 512 channels to `hidden_dim`. Instantiating with the defaults yields a
+ configuration close to `microsoft/table-transformer-detection`. Fields
+ mirror the model constructor and serialize flat to a repo's `zm_config.json`.
+
+ Args:
+ hidden_dim (`int`, *optional*, defaults to 256):
+ Dimensionality of the transformer encoder and decoder layers.
+ num_heads (`int`, *optional*, defaults to 8):
+ Number of attention heads for each attention layer in the transformer.
+ num_encoder_layers (`int`, *optional*, defaults to 6):
+ Number of encoder layers.
+ num_decoder_layers (`int`, *optional*, defaults to 6):
+ Number of decoder layers.
+ dim_feedforward (`int`, *optional*, defaults to 2048):
+ Dimension of the feed-forward ("intermediate") layer in the transformer.
+ dropout_rate (`float`, *optional*, defaults to 0.1):
+ Dropout probability in the transformer layers.
+ num_queries (`int`, *optional*, defaults to 15):
+ Number of object queries, i.e. detection slots. The maximal number of
+ objects [`TableTransformerDetect`] can detect in a single image. The
+ detection checkpoint uses 15, the structure-recognition checkpoints 125.
+ num_classes (`int`, *optional*, defaults to 3):
+ Number of object classes, including the no-object class (table detection:
+ 2 + 1; table-structure recognition: 6 + 1).
+ image_size (`int`, *optional*, defaults to 800):
+ Square input resolution the model is built for.
+
+ Examples:
+
+ ```python
+ >>> from zeromodels.models.table_transformer import (
+ ... TableTransformerConfig,
+ ... TableTransformerDetect,
+ ... )
+
+ >>> # Initializing a microsoft/table-transformer-detection style configuration
+ >>> configuration = TableTransformerConfig()
+
+ >>> # Initializing a model (with random weights) from that configuration
+ >>> model = TableTransformerDetect(configuration)
+
+ >>> # Accessing the model configuration
+ >>> configuration = model.config
+ ```"""
+
+ model_type = "table-transformer"
+
+ hidden_dim: int = 256
+ num_heads: int = 8
+ num_encoder_layers: int = 6
+ num_decoder_layers: int = 6
+ dim_feedforward: int = 2048
+ dropout_rate: float = 0.1
+ num_queries: int = 15
+ num_classes: int = 3
+ image_size: int = 800
diff --git a/zeromodels/models/table_transformer/table_transformer_image_processor.py b/zeromodels/models/table_transformer/table_transformer_image_processor.py
new file mode 100644
index 00000000..477ad0d0
--- /dev/null
+++ b/zeromodels/models/table_transformer/table_transformer_image_processor.py
@@ -0,0 +1,154 @@
+from typing import Dict, List, Optional, Tuple
+
+import keras
+from keras import ops
+
+from zeromodels.base import BaseImageProcessor
+
+TABLE_DETECTION_LABELS = ("table", "table rotated")
+TABLE_STRUCTURE_LABELS = (
+ "table",
+ "table column",
+ "table row",
+ "table column header",
+ "table projected row header",
+ "table spanning cell",
+)
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerImageProcessor(BaseImageProcessor):
+ """Preprocess images for Table Transformer inference.
+
+ Use this when the model is created with ``include_normalization=False``.
+ 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.
+
+ Args:
+ size: Target size as ``{"height": H, "width": W}``. Default:
+ ``{"height": 800, "width": 800}``.
+ resample: Interpolation method (``"nearest"``, ``"bilinear"``, or
+ ``"bicubic"``).
+ do_rescale: Whether to divide pixel values by 255.
+ rescale_factor: Rescale factor (default ``1/255``).
+ do_normalize: Whether to apply ImageNet normalization.
+ image_mean: Per-channel mean for normalization. Default:
+ ``(0.485, 0.456, 0.406)``.
+ image_std: Per-channel std for normalization. Default:
+ ``(0.229, 0.224, 0.225)``.
+ return_tensor: If True return a Keras tensor, otherwise a numpy array.
+ data_format: ``"channels_first"`` / ``"channels_last"``; ``None``
+ resolves to ``keras.backend.image_data_format()``.
+ """
+
+ def __init__(
+ self,
+ size: Optional[Dict[str, int]] = None,
+ resample: str = "bilinear",
+ do_rescale: bool = True,
+ rescale_factor: float = 1 / 255,
+ do_normalize: bool = True,
+ image_mean: Optional[Tuple[float, ...]] = None,
+ image_std: Optional[Tuple[float, ...]] = None,
+ return_tensor: bool = True,
+ data_format: Optional[str] = None,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.size = size if size is not None else {"height": 800, "width": 800}
+ self.resample = resample
+ self.do_rescale = do_rescale
+ self.rescale_factor = rescale_factor
+ self.do_normalize = do_normalize
+ self.image_mean = (
+ image_mean if image_mean is not None else (0.485, 0.456, 0.406)
+ )
+ self.image_std = image_std if image_std is not None else (0.229, 0.224, 0.225)
+ self.return_tensor = return_tensor
+ self.data_format = data_format
+
+ 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(
+ image,
+ target_size=(self.size["height"], self.size["width"]),
+ 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,
+ interpolation=self.resample,
+ antialias=False,
+ data_format=self.data_format,
+ )
+ if self.do_rescale and self.rescale_factor != 1 / 255:
+ image = image * (self.rescale_factor * 255)
+
+ if not self.return_tensor:
+ image = ops.convert_to_numpy(image)
+
+ return {"pixel_values": image}
+
+ def post_process_object_detection(
+ self, outputs, threshold=0.7, target_sizes=None, label_names=None
+ ):
+ return table_transformer_post_process_object_detection(
+ outputs,
+ threshold=threshold,
+ target_sizes=target_sizes,
+ label_names=label_names,
+ )
+
+
+def table_transformer_post_process_object_detection(
+ outputs: Dict[str, keras.KerasTensor],
+ threshold: float = 0.7,
+ target_sizes: Optional[List[Tuple[int, int]]] = None,
+ label_names: Optional[List[str]] = None,
+) -> list:
+ logits = ops.convert_to_tensor(outputs["logits"])
+ boxes = ops.convert_to_tensor(outputs["pred_boxes"])
+ batch_size = logits.shape[0]
+
+ # Drop the trailing no-object class, then reduce to one score and label per query.
+ probs = ops.softmax(logits, axis=-1)[:, :, :-1]
+ scores_all = ops.max(probs, axis=-1)
+ labels_all = ops.argmax(probs, axis=-1)
+
+ cx, cy, w, h = boxes[:, :, 0], boxes[:, :, 1], boxes[:, :, 2], boxes[:, :, 3]
+ xyxy_all = ops.stack([cx - w / 2, cy - h / 2, cx + w / 2, cy + h / 2], axis=-1)
+
+ results = []
+ for i in range(batch_size):
+ keep = ops.nonzero(ops.greater(scores_all[i], threshold))[0]
+ scores = ops.take(scores_all[i], keep, axis=0)
+ labels = ops.take(labels_all[i], keep, axis=0)
+ xyxy_boxes = ops.take(xyxy_all[i], keep, axis=0)
+
+ if target_sizes is not None:
+ img_h, img_w = target_sizes[i]
+ scale = ops.convert_to_tensor([img_w, img_h, img_w, img_h], dtype="float32")
+ xyxy_boxes = xyxy_boxes * scale
+
+ scores = ops.convert_to_numpy(scores)
+ labels = ops.convert_to_numpy(labels)
+ xyxy_boxes = ops.convert_to_numpy(xyxy_boxes)
+
+ names = label_names if label_names is not None else TABLE_STRUCTURE_LABELS
+ mapped_names = [
+ names[label] if label < len(names) else f"class_{label}" for label in labels
+ ]
+
+ results.append(
+ {
+ "scores": scores,
+ "labels": labels,
+ "label_names": mapped_names,
+ "boxes": xyxy_boxes,
+ }
+ )
+
+ return results
diff --git a/zeromodels/models/table_transformer/table_transformer_layers.py b/zeromodels/models/table_transformer/table_transformer_layers.py
new file mode 100644
index 00000000..40a5fd2a
--- /dev/null
+++ b/zeromodels/models/table_transformer/table_transformer_layers.py
@@ -0,0 +1,321 @@
+import math
+
+import keras
+from keras import layers, ops
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerExpandQueryEmbedding(layers.Layer):
+ """Expands learned query embeddings to match the batch dimension.
+
+ Wraps a standard `Embedding` layer and broadcasts its output along a new
+ batch axis so that each sample in the batch receives the same set of learned
+ object queries. Used to produce the positional part of the object queries
+ fed into the Table Transformer decoder.
+
+ Reference:
+ - [PubTables-1M](https://arxiv.org/abs/2110.00061)
+ - [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872)
+
+ Args:
+ num_queries: Integer, number of object queries (maximum detections per
+ image).
+ hidden_dim: Integer, embedding dimension for each query.
+ **kwargs: Additional keyword arguments passed to the `Layer` class.
+
+ Input Shape:
+ Any tensor whose first dimension is the batch size. Only `batch_size` is
+ read from the input; the content is unused.
+
+ Output Shape:
+ 3D tensor: `(batch_size, num_queries, hidden_dim)`.
+ """
+
+ def __init__(self, num_queries, hidden_dim, **kwargs):
+ super().__init__(**kwargs)
+ self.num_queries = num_queries
+ self.hidden_dim = hidden_dim
+ self.embedding = layers.Embedding(
+ num_queries,
+ hidden_dim,
+ name="embedding",
+ )
+
+ def call(self, batch_ref):
+ batch_size = ops.shape(batch_ref)[0]
+ indices = ops.arange(self.num_queries)
+ query_embed = self.embedding(indices)
+ query_embed = ops.expand_dims(query_embed, axis=0)
+ query_embed = ops.tile(query_embed, [batch_size, 1, 1])
+ return query_embed
+
+ def get_config(self):
+ config = super().get_config()
+ config.update(
+ {
+ "num_queries": self.num_queries,
+ "hidden_dim": self.hidden_dim,
+ }
+ )
+ return config
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerFlattenFeatures(layers.Layer):
+ """Flattens spatial feature maps into a 1D token sequence.
+
+ Reshapes a 4D spatial tensor into a 3D sequence tensor suitable for
+ transformer input by collapsing the height and width dimensions into a
+ single sequence dimension.
+
+ Reference:
+ - [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872)
+
+ Args:
+ hidden_dim: Integer, channel dimension of the input feature map. Used as
+ the last dimension in the reshape target.
+ **kwargs: Additional keyword arguments passed to the `Layer` class.
+
+ Input Shape:
+ 4D tensor: `(batch_size, height, width, hidden_dim)`.
+
+ Output Shape:
+ 3D tensor: `(batch_size, height * width, hidden_dim)`.
+ """
+
+ def __init__(self, hidden_dim, **kwargs):
+ super().__init__(**kwargs)
+ self.hidden_dim = hidden_dim
+
+ def call(self, inputs):
+ shape = ops.shape(inputs)
+ data_format = keras.config.image_data_format()
+ if data_format == "channels_first":
+ x = ops.transpose(inputs, [0, 2, 3, 1])
+ return ops.reshape(x, [shape[0], shape[2] * shape[3], self.hidden_dim])
+ return ops.reshape(inputs, [shape[0], shape[1] * shape[2], self.hidden_dim])
+
+ def get_config(self):
+ config = super().get_config()
+ config.update({"hidden_dim": self.hidden_dim})
+ return config
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerPositionEmbeddingSine(layers.Layer):
+ """Fixed sinusoidal 2D positional embedding for spatial feature maps.
+
+ Generates non-learnable sine/cosine positional encodings that encode the row
+ and column position of each spatial location. Half of the embedding
+ dimension encodes the vertical position and the other half the horizontal
+ position, using sinusoidal functions at geometrically spaced frequencies.
+ Matches the reference Table Transformer / DETR sine position embedding for a
+ full (unpadded) image, whose pixel mask cumulative sum reduces to
+ `arange(1, size + 1)`.
+
+ Reference:
+ - [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872)
+
+ Args:
+ hidden_dim: Integer, total embedding dimension. Half is allocated to row
+ embeddings and half to column embeddings. Defaults to `256`.
+ temperature: Integer, temperature scaling factor for the sinusoidal
+ frequencies. Defaults to `10000`.
+ normalize: Boolean, whether to normalize position coordinates to the
+ range `[0, 2*pi]` before computing the encoding. Defaults to `True`.
+ eps: Float, small constant added during normalization to prevent
+ division by zero. Defaults to `1e-6`.
+ **kwargs: Additional keyword arguments passed to the `Layer` class.
+
+ Input Shape:
+ 4D tensor: `(batch_size, height, width, channels)`. Only the spatial
+ dimensions are used; the channel dimension is ignored.
+
+ Output Shape:
+ 4D tensor: `(batch_size, height, width, hidden_dim)`.
+ """
+
+ def __init__(
+ self,
+ hidden_dim=256,
+ temperature=10000,
+ normalize=True,
+ eps=1e-6,
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.hidden_dim = hidden_dim
+ self.temperature = temperature
+ self.normalize = normalize
+ self.eps = eps
+ self.num_pos_feats = hidden_dim // 2
+
+ def call(self, inputs):
+ shape = ops.shape(inputs)
+ batch_size = shape[0]
+ data_format = keras.config.image_data_format()
+ if data_format == "channels_first":
+ h = shape[2]
+ w = shape[3]
+ else:
+ h = shape[1]
+ w = shape[2]
+
+ y_embed = ops.cast(
+ ops.repeat(
+ ops.expand_dims(ops.arange(1, h + 1, dtype="float32"), axis=1),
+ w,
+ axis=1,
+ ),
+ dtype="float32",
+ )
+ x_embed = ops.cast(
+ ops.repeat(
+ ops.expand_dims(ops.arange(1, w + 1, dtype="float32"), axis=0),
+ h,
+ axis=0,
+ ),
+ dtype="float32",
+ )
+
+ if self.normalize:
+ y_embed = y_embed / (y_embed[-1:, :] + self.eps) * 2 * math.pi
+ x_embed = x_embed / (x_embed[:, -1:] + self.eps) * 2 * math.pi
+
+ dim_t = ops.arange(self.num_pos_feats, dtype="float32")
+ dim_t = self.temperature ** (2 * (dim_t // 2) / self.num_pos_feats)
+
+ pos_x = ops.expand_dims(x_embed, axis=-1) / dim_t
+ pos_y = ops.expand_dims(y_embed, axis=-1) / dim_t
+
+ pos_x_sin = ops.sin(pos_x[:, :, 0::2])
+ pos_x_cos = ops.cos(pos_x[:, :, 1::2])
+ pos_x = ops.reshape(
+ ops.stack([pos_x_sin, pos_x_cos], axis=-1),
+ [h, w, self.num_pos_feats],
+ )
+
+ pos_y_sin = ops.sin(pos_y[:, :, 0::2])
+ pos_y_cos = ops.cos(pos_y[:, :, 1::2])
+ pos_y = ops.reshape(
+ ops.stack([pos_y_sin, pos_y_cos], axis=-1),
+ [h, w, self.num_pos_feats],
+ )
+
+ pos = ops.concatenate([pos_y, pos_x], axis=-1)
+ pos = ops.expand_dims(pos, axis=0)
+ pos = ops.broadcast_to(pos, [batch_size, h, w, self.hidden_dim])
+
+ if data_format == "channels_first":
+ pos = ops.transpose(pos, [0, 3, 1, 2])
+
+ return pos
+
+ def get_config(self):
+ config = super().get_config()
+ config.update(
+ {
+ "hidden_dim": self.hidden_dim,
+ "temperature": self.temperature,
+ "normalize": self.normalize,
+ "eps": self.eps,
+ }
+ )
+ return config
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerMultiHeadAttention(layers.Layer):
+ """Multi-head attention layer for the Table Transformer transformer.
+
+ Implements scaled dot-product multi-head attention with separate query, key,
+ and value projections followed by an output projection. The projection
+ naming matches the reference layout (`q_proj`, `k_proj`, `v_proj`,
+ `out_proj`) to simplify weight transfer from pretrained models. Used in both
+ the encoder (self-attention) and decoder (self-attention and
+ cross-attention) stages.
+
+ Reference:
+ - [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872)
+
+ Args:
+ hidden_dim: Integer, total model dimension. Must be divisible by
+ `num_heads`.
+ num_heads: Integer, number of parallel attention heads.
+ dropout_rate: Float, dropout rate applied to the attention weight matrix.
+ Defaults to `0.0`.
+ block_prefix: String, name prefix for the internal dense layers
+ (`q_proj`, `k_proj`, `v_proj`, `out_proj`). Defaults to `""`.
+ **kwargs: Additional keyword arguments passed to the `Layer` class.
+
+ Input Shape:
+ Three 3D tensors:
+ - `query`: `(batch_size, seq_len_q, hidden_dim)`
+ - `key`: `(batch_size, seq_len_k, hidden_dim)`
+ - `value`: `(batch_size, seq_len_k, hidden_dim)`
+
+ Output Shape:
+ 3D tensor: `(batch_size, seq_len_q, hidden_dim)`.
+ """
+
+ def __init__(
+ self,
+ hidden_dim,
+ num_heads,
+ dropout_rate=0.0,
+ block_prefix="",
+ **kwargs,
+ ):
+ super().__init__(**kwargs)
+ self.hidden_dim = hidden_dim
+ self.num_heads = num_heads
+ self.head_dim = hidden_dim // num_heads
+ self.scale = self.head_dim**-0.5
+ self.dropout_rate = dropout_rate
+ self.block_prefix = block_prefix
+
+ self.q_proj = layers.Dense(hidden_dim, name=f"{block_prefix}_q_proj")
+ self.k_proj = layers.Dense(hidden_dim, name=f"{block_prefix}_k_proj")
+ self.v_proj = layers.Dense(hidden_dim, name=f"{block_prefix}_v_proj")
+ self.out_proj = layers.Dense(hidden_dim, name=f"{block_prefix}_out_proj")
+ self.attn_dropout = layers.Dropout(dropout_rate)
+
+ def call(self, query, key, value, training=None):
+ batch_size = ops.shape(query)[0]
+ seq_len_q = ops.shape(query)[1]
+ seq_len_k = ops.shape(key)[1]
+
+ q = self.q_proj(query)
+ k = self.k_proj(key)
+ v = self.v_proj(value)
+
+ q = ops.reshape(q, [batch_size, seq_len_q, self.num_heads, self.head_dim])
+ k = ops.reshape(k, [batch_size, seq_len_k, self.num_heads, self.head_dim])
+ v = ops.reshape(v, [batch_size, seq_len_k, self.num_heads, self.head_dim])
+
+ q = ops.transpose(q, [0, 2, 1, 3])
+ k = ops.transpose(k, [0, 2, 1, 3])
+ v = ops.transpose(v, [0, 2, 1, 3])
+
+ attn_weights = ops.matmul(q, ops.transpose(k, [0, 1, 3, 2])) * self.scale
+ attn_weights = ops.softmax(attn_weights, axis=-1)
+ attn_weights = self.attn_dropout(attn_weights, training=training)
+
+ attn_output = ops.matmul(attn_weights, v)
+ attn_output = ops.transpose(attn_output, [0, 2, 1, 3])
+ attn_output = ops.reshape(attn_output, [batch_size, seq_len_q, self.hidden_dim])
+ attn_output = self.out_proj(attn_output)
+
+ return attn_output
+
+ def get_config(self):
+ config = super().get_config()
+ config.update(
+ {
+ "hidden_dim": self.hidden_dim,
+ "num_heads": self.num_heads,
+ "dropout_rate": self.dropout_rate,
+ "block_prefix": self.block_prefix,
+ }
+ )
+ return config
diff --git a/zeromodels/models/table_transformer/table_transformer_model.py b/zeromodels/models/table_transformer/table_transformer_model.py
new file mode 100644
index 00000000..3819d794
--- /dev/null
+++ b/zeromodels/models/table_transformer/table_transformer_model.py
@@ -0,0 +1,654 @@
+import keras
+from keras import layers, ops, utils
+
+from zeromodels.base import BaseModel
+from zeromodels.base.base_model import hf_num_classes
+from zeromodels.conversion import copy_weights_by_path_suffix
+from zeromodels.models.table_transformer.table_transformer_layers import (
+ TableTransformerExpandQueryEmbedding,
+ TableTransformerFlattenFeatures,
+ TableTransformerMultiHeadAttention,
+ TableTransformerPositionEmbeddingSine,
+)
+from zeromodels.utils import standardize_input_shape
+
+from .table_transformer_config import TableTransformerConfig
+
+
+def table_transformer_encoder_layer(
+ x,
+ pos_embed,
+ hidden_dim,
+ num_heads,
+ dim_feedforward,
+ dropout_rate=0.1,
+ block_prefix="encoder_layers_0",
+):
+ self_attn = TableTransformerMultiHeadAttention(
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ dropout_rate=dropout_rate,
+ block_prefix=f"{block_prefix}_self_attn",
+ name=f"{block_prefix}_self_attn",
+ )
+ residual = x
+ h = layers.LayerNormalization(
+ epsilon=1e-5,
+ name=f"{block_prefix}_self_attn_layer_norm",
+ )(x)
+ q = k = layers.Add(name=f"{block_prefix}_sa_qk_add")([h, pos_embed])
+ attn_output = self_attn(q, k, h)
+ attn_output = layers.Dropout(dropout_rate, name=f"{block_prefix}_sa_drop")(
+ attn_output
+ )
+ x = layers.Add(name=f"{block_prefix}_sa_residual")([residual, attn_output])
+
+ residual = x
+ h = layers.LayerNormalization(
+ epsilon=1e-5,
+ name=f"{block_prefix}_final_layer_norm",
+ )(x)
+ ff_output = layers.Dense(
+ dim_feedforward,
+ activation="relu",
+ name=f"{block_prefix}_fc1",
+ )(h)
+ ff_output = layers.Dropout(dropout_rate, name=f"{block_prefix}_ff_drop")(ff_output)
+ ff_output = layers.Dense(hidden_dim, name=f"{block_prefix}_fc2")(ff_output)
+ x = layers.Add(name=f"{block_prefix}_ff_residual")([residual, ff_output])
+
+ return x
+
+
+def table_transformer_decoder_layer(
+ x,
+ memory,
+ pos_embed,
+ query_pos,
+ hidden_dim,
+ num_heads,
+ dim_feedforward,
+ dropout_rate=0.1,
+ block_prefix="decoder_layers_0",
+):
+ self_attn = TableTransformerMultiHeadAttention(
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ dropout_rate=dropout_rate,
+ block_prefix=f"{block_prefix}_self_attn",
+ name=f"{block_prefix}_self_attn",
+ )
+
+ residual = x
+ h = layers.LayerNormalization(
+ epsilon=1e-5,
+ name=f"{block_prefix}_self_attn_layer_norm",
+ )(x)
+ q = k = layers.Add(name=f"{block_prefix}_sa_qk_add")([h, query_pos])
+ attn_output = self_attn(q, k, h)
+ attn_output = layers.Dropout(dropout_rate, name=f"{block_prefix}_sa_drop")(
+ attn_output
+ )
+ x = layers.Add(name=f"{block_prefix}_sa_residual")([residual, attn_output])
+
+ cross_attn = TableTransformerMultiHeadAttention(
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ dropout_rate=dropout_rate,
+ block_prefix=f"{block_prefix}_encoder_attn",
+ name=f"{block_prefix}_encoder_attn",
+ )
+
+ residual = x
+ h = layers.LayerNormalization(
+ epsilon=1e-5,
+ name=f"{block_prefix}_encoder_attn_layer_norm",
+ )(x)
+ q_cross = layers.Add(name=f"{block_prefix}_ca_q_add")([h, query_pos])
+ k_cross = layers.Add(name=f"{block_prefix}_ca_k_add")([memory, pos_embed])
+ cross_output = cross_attn(q_cross, k_cross, memory)
+ cross_output = layers.Dropout(dropout_rate, name=f"{block_prefix}_ca_drop")(
+ cross_output
+ )
+ x = layers.Add(name=f"{block_prefix}_ca_residual")([residual, cross_output])
+
+ residual = x
+ h = layers.LayerNormalization(
+ epsilon=1e-5,
+ name=f"{block_prefix}_final_layer_norm",
+ )(x)
+ ff_output = layers.Dense(
+ dim_feedforward,
+ activation="relu",
+ name=f"{block_prefix}_fc1",
+ )(h)
+ ff_output = layers.Dropout(dropout_rate, name=f"{block_prefix}_ff_drop")(ff_output)
+ ff_output = layers.Dense(hidden_dim, name=f"{block_prefix}_fc2")(ff_output)
+ x = layers.Add(name=f"{block_prefix}_ff_residual")([residual, ff_output])
+
+ return x
+
+
+def table_transformer_backbone(
+ input_tensor,
+ data_format="channels_last",
+ channels_axis=-1,
+):
+ depths = [2, 2, 2, 2]
+ filters_list = [64, 128, 256, 512]
+
+ x = input_tensor
+ x = layers.ZeroPadding2D(padding=3, data_format=data_format)(x)
+ x = layers.Conv2D(
+ 64,
+ 7,
+ strides=2,
+ padding="valid",
+ use_bias=False,
+ data_format=data_format,
+ name="backbone_conv1",
+ )(x)
+ x = layers.BatchNormalization(
+ axis=channels_axis,
+ epsilon=1e-5,
+ momentum=0.1,
+ name="backbone_bn1",
+ )(x)
+ x = layers.ReLU()(x)
+ x = layers.ZeroPadding2D(padding=1, data_format=data_format)(x)
+ x = layers.MaxPooling2D(
+ pool_size=3,
+ strides=2,
+ padding="valid",
+ data_format=data_format,
+ )(x)
+
+ stage_outputs = []
+ for stage_idx, depth in enumerate(depths):
+ filters = filters_list[stage_idx]
+ for block_idx in range(depth):
+ prefix = f"backbone_layer{stage_idx + 1}_{block_idx}"
+ strides = 2 if block_idx == 0 and stage_idx > 0 else 1
+ residual = x
+
+ if strides > 1:
+ x = layers.ZeroPadding2D(padding=1, data_format=data_format)(x)
+ x = layers.Conv2D(
+ filters,
+ 3,
+ strides=strides,
+ padding="valid",
+ use_bias=False,
+ data_format=data_format,
+ name=f"{prefix}_conv1",
+ )(x)
+ else:
+ x = layers.Conv2D(
+ filters,
+ 3,
+ strides=1,
+ padding="same",
+ use_bias=False,
+ data_format=data_format,
+ name=f"{prefix}_conv1",
+ )(x)
+ x = layers.BatchNormalization(
+ axis=channels_axis,
+ epsilon=1e-5,
+ momentum=0.1,
+ name=f"{prefix}_bn1",
+ )(x)
+ x = layers.ReLU()(x)
+
+ x = layers.Conv2D(
+ filters,
+ 3,
+ strides=1,
+ padding="same",
+ use_bias=False,
+ data_format=data_format,
+ name=f"{prefix}_conv2",
+ )(x)
+ x = layers.BatchNormalization(
+ axis=channels_axis,
+ epsilon=1e-5,
+ momentum=0.1,
+ name=f"{prefix}_bn2",
+ )(x)
+
+ in_channels = residual.shape[channels_axis]
+ if strides != 1 or in_channels != filters:
+ residual = layers.Conv2D(
+ filters,
+ 1,
+ strides=strides,
+ padding="valid",
+ use_bias=False,
+ data_format=data_format,
+ name=f"{prefix}_downsample_conv",
+ )(residual)
+ residual = layers.BatchNormalization(
+ axis=channels_axis,
+ epsilon=1e-5,
+ momentum=0.1,
+ name=f"{prefix}_downsample_bn",
+ )(residual)
+
+ x = layers.Add()([x, residual])
+ x = layers.ReLU()(x)
+ stage_outputs.append(x)
+
+ return tuple(stage_outputs)
+
+
+def table_transformer_encoder(
+ backbone_features,
+ hidden_dim,
+ num_heads,
+ num_encoder_layers,
+ dim_feedforward,
+ dropout_rate,
+):
+ data_format = keras.config.image_data_format()
+
+ projected = layers.Conv2D(
+ hidden_dim,
+ 1,
+ padding="valid",
+ data_format=data_format,
+ name="input_projection",
+ )(backbone_features)
+
+ pos_embed = TableTransformerPositionEmbeddingSine(
+ hidden_dim=hidden_dim,
+ name="position_embedding",
+ )(projected)
+
+ src = TableTransformerFlattenFeatures(hidden_dim, name="flatten_src")(projected)
+ pos = TableTransformerFlattenFeatures(hidden_dim, name="flatten_pos")(pos_embed)
+
+ encoder_output = src
+ for i in range(num_encoder_layers):
+ encoder_output = table_transformer_encoder_layer(
+ encoder_output,
+ pos,
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ block_prefix=f"encoder_layers_{i}",
+ )
+ encoder_output = layers.LayerNormalization(
+ epsilon=1e-5,
+ name="encoder_layernorm",
+ )(encoder_output)
+
+ return encoder_output, pos
+
+
+def table_transformer_decoder(
+ encoder_output,
+ pos,
+ hidden_dim,
+ num_heads,
+ num_decoder_layers,
+ dim_feedforward,
+ dropout_rate,
+ num_queries,
+):
+ query_embed = TableTransformerExpandQueryEmbedding(
+ num_queries,
+ hidden_dim,
+ name="query_position_embeddings",
+ )(encoder_output)
+
+ decoder_output = ops.zeros_like(query_embed)
+ for i in range(num_decoder_layers):
+ decoder_output = table_transformer_decoder_layer(
+ decoder_output,
+ encoder_output,
+ pos,
+ query_embed,
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ block_prefix=f"decoder_layers_{i}",
+ )
+
+ last_hidden_state = layers.LayerNormalization(
+ epsilon=1e-5,
+ name="decoder_layernorm",
+ )(decoder_output)
+
+ return last_hidden_state
+
+
+def table_transformer_functional(
+ inputs,
+ hidden_dim,
+ num_heads,
+ num_encoder_layers,
+ num_decoder_layers,
+ dim_feedforward,
+ dropout_rate,
+ num_queries,
+):
+ data_format = keras.config.image_data_format()
+ channels_axis = -1 if data_format == "channels_last" else 1
+
+ backbone_features = table_transformer_backbone(
+ inputs,
+ data_format=data_format,
+ channels_axis=channels_axis,
+ )
+ encoder_output, pos = table_transformer_encoder(
+ backbone_features[-1],
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ num_encoder_layers=num_encoder_layers,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ )
+ last_hidden_state = table_transformer_decoder(
+ encoder_output,
+ pos,
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ num_decoder_layers=num_decoder_layers,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ num_queries=num_queries,
+ )
+ return last_hidden_state
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerModel(BaseModel):
+ """Table Transformer backbone + transformer encoder/decoder (no heads).
+
+ Matches the reference ``TableTransformerModel``: outputs the decoder
+ ``last_hidden_state`` with shape ``(B, num_queries, hidden_dim)``. Wraps the
+ functional graph built by :func:`table_transformer_functional`, a ResNet-18
+ backbone, a stack of pre-norm transformer encoder layers with sine 2D
+ position embeddings plus a final encoder LayerNorm, and a stack of pre-norm
+ transformer decoder layers with learned object queries plus a final decoder
+ LayerNorm. Classification and bbox heads are pruned from the output graph;
+ use :class:`TableTransformerDetect` for full detection outputs.
+
+ Reference:
+ - `PubTables-1M `_
+ - `End-to-End Object Detection with Transformers
+ `_
+
+ Args:
+ hidden_dim: Transformer model dimension (channel width of both encoder
+ and decoder, and of the input projection that reduces the backbone's
+ 512-channel feature map). Defaults to ``256``.
+ num_heads: Number of attention heads in every transformer
+ self-attention and cross-attention layer. Defaults to ``8``.
+ num_encoder_layers: Number of stacked transformer encoder layers.
+ Defaults to ``6``.
+ num_decoder_layers: Number of stacked transformer decoder layers.
+ Defaults to ``6``.
+ dim_feedforward: FFN intermediate dimension inside each encoder /
+ decoder layer. Defaults to ``2048``.
+ dropout_rate: Dropout probability used in attention and FFN sub-layers.
+ Defaults to ``0.1``.
+ num_queries: Number of learned object queries, also the number of
+ detections produced per image. Defaults to ``15``.
+ image_size: Input image specification. Accepts an integer ``N`` (builds
+ an ``N x N x 3`` square input), a 2-tuple ``(H, W)`` (assumes 3
+ channels), or a 3-tuple ordered to match the active
+ ``keras.config.image_data_format()``: ``(H, W, C)`` for
+ ``channels_last`` or ``(C, H, W)`` for ``channels_first``. Defaults
+ to ``800``.
+ input_tensor: Optional pre-existing Keras tensor to use as the model
+ input instead of creating a new :class:`Input`. Defaults to ``None``.
+ name: Model name. Defaults to ``"TableTransformerModel"``.
+ **kwargs: Additional keyword arguments forwarded to :class:`BaseModel`.
+ """
+
+ BASE_MODEL_CONFIG = None
+ BASE_WEIGHT_CONFIG = None
+ HF_MODEL_TYPE = "table-transformer"
+
+ def __init__(
+ self,
+ hidden_dim=256,
+ num_heads=8,
+ num_encoder_layers=6,
+ num_decoder_layers=6,
+ dim_feedforward=2048,
+ dropout_rate=0.1,
+ num_queries=15,
+ image_size=800,
+ input_tensor=None,
+ name="TableTransformerModel",
+ **kwargs,
+ ):
+ data_format = keras.config.image_data_format()
+ image_size = standardize_input_shape(image_size, data_format)
+
+ if input_tensor is None:
+ img_input = layers.Input(shape=image_size)
+ else:
+ if not utils.is_keras_tensor(input_tensor):
+ img_input = layers.Input(tensor=input_tensor, shape=image_size)
+ else:
+ img_input = input_tensor
+
+ last_hidden_state = table_transformer_functional(
+ img_input,
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ num_encoder_layers=num_encoder_layers,
+ num_decoder_layers=num_decoder_layers,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ num_queries=num_queries,
+ )
+
+ super().__init__(
+ inputs=img_input, outputs=last_hidden_state, name=name, **kwargs
+ )
+
+ self.hidden_dim = hidden_dim
+ self.num_heads = num_heads
+ self.num_encoder_layers = num_encoder_layers
+ self.num_decoder_layers = num_decoder_layers
+ self.dim_feedforward = dim_feedforward
+ self.dropout_rate = dropout_rate
+ self.num_queries = num_queries
+ self.image_size = image_size
+ self.input_tensor = input_tensor
+
+ def get_config(self):
+ config = super().get_config()
+ config.update(
+ {
+ "hidden_dim": self.hidden_dim,
+ "num_heads": self.num_heads,
+ "num_encoder_layers": self.num_encoder_layers,
+ "num_decoder_layers": self.num_decoder_layers,
+ "dim_feedforward": self.dim_feedforward,
+ "dropout_rate": self.dropout_rate,
+ "num_queries": self.num_queries,
+ "image_size": self.image_size,
+ "input_tensor": self.input_tensor,
+ "name": self.name,
+ }
+ )
+ return config
+
+ @classmethod
+ def from_config(cls, config):
+ return cls(**config)
+
+ @classmethod
+ def config_from_hf(cls, hf_config):
+ return {
+ "hidden_dim": hf_config["d_model"],
+ "num_heads": hf_config["encoder_attention_heads"],
+ "num_encoder_layers": hf_config["encoder_layers"],
+ "num_decoder_layers": hf_config["decoder_layers"],
+ "dim_feedforward": hf_config["encoder_ffn_dim"],
+ "dropout_rate": hf_config["dropout"],
+ "num_queries": hf_config["num_queries"],
+ }
+
+ @classmethod
+ def from_hf(cls, hf_id, load_weights=True, skip_mismatch=False, **kwargs):
+ model = super().from_hf(hf_id, load_weights=False, **kwargs)
+ if load_weights:
+ src = TableTransformerDetect.from_hf(hf_id, skip_mismatch=skip_mismatch)
+ unmatched = copy_weights_by_path_suffix(src, model)
+ if unmatched and not skip_mismatch:
+ raise ValueError(
+ f"{cls.__name__}.from_hf: {len(unmatched)} weight(s) not "
+ f"matched from the {type(src).__name__} checkpoint: "
+ f"{unmatched[:5]}"
+ )
+ del src
+ return model
+
+
+@keras.saving.register_keras_serializable(package="zeromodels")
+class TableTransformerDetect(BaseModel):
+ """Table Transformer object detection model (transformer + heads).
+
+ The same architecture serves both Table Transformer tasks; only
+ ``num_queries`` and ``num_classes`` differ between the checkpoints:
+
+ - table **detection** (``microsoft/table-transformer-detection``):
+ ``num_queries=15``, ``num_classes=3`` (table, table rotated, no-object).
+ - table **structure recognition**
+ (``microsoft/table-transformer-structure-recognition`` and the v1.1
+ variants): ``num_queries=125``, ``num_classes=7`` (table, column, row,
+ column header, projected row header, spanning cell, no-object).
+
+ Output dict:
+
+ .. code-block:: python
+
+ out = model(images)
+ out["logits"] # (B, num_queries, num_classes): class logits
+ out["pred_boxes"] # (B, num_queries, 4): sigmoid cxcywh in [0, 1]
+
+ Reference:
+ - [PubTables-1M](https://arxiv.org/abs/2110.00061)
+ - [End-to-End Object Detection with Transformers](https://arxiv.org/abs/2005.12872)
+
+ Loads pretrained weights via ``TableTransformerDetect.from_weights(...)``.
+ See ``BaseModel.from_weights`` for the loading API.
+ """
+
+ BASE_MODEL_CONFIG = None
+ config_class = TableTransformerConfig
+ # Weights load by Hub repo id, e.g.
+ # from_weights("zeromodels/table-transformer-detection"), via zm_config.json
+ # on the repo (no url table in the package).
+ BASE_WEIGHT_CONFIG = None
+ HF_MODEL_TYPE = "table-transformer"
+
+ def __init__(
+ self,
+ hidden_dim=256,
+ num_heads=8,
+ num_encoder_layers=6,
+ num_decoder_layers=6,
+ dim_feedforward=2048,
+ dropout_rate=0.1,
+ num_queries=15,
+ num_classes=3,
+ image_size=800,
+ input_tensor=None,
+ name="TableTransformerDetect",
+ **kwargs,
+ ):
+ base = TableTransformerModel(
+ hidden_dim=hidden_dim,
+ num_heads=num_heads,
+ num_encoder_layers=num_encoder_layers,
+ num_decoder_layers=num_decoder_layers,
+ dim_feedforward=dim_feedforward,
+ dropout_rate=dropout_rate,
+ num_queries=num_queries,
+ image_size=image_size,
+ input_tensor=input_tensor,
+ name=f"{name}_model",
+ )
+ last_hidden_state = base.output
+
+ logits = layers.Dense(
+ num_classes,
+ name="class_labels_classifier",
+ )(last_hidden_state)
+
+ bbox = layers.Dense(hidden_dim, activation="relu", name="bbox_predictor_0")(
+ last_hidden_state
+ )
+ bbox = layers.Dense(hidden_dim, activation="relu", name="bbox_predictor_1")(
+ bbox
+ )
+ bbox = layers.Dense(4, name="bbox_predictor_2")(bbox)
+ bbox = layers.Activation("sigmoid", name="bbox_sigmoid")(bbox)
+
+ outputs = {"logits": logits, "pred_boxes": bbox}
+
+ super().__init__(inputs=base.input, outputs=outputs, name=name, **kwargs)
+
+ self.hidden_dim = hidden_dim
+ self.num_heads = num_heads
+ self.num_encoder_layers = num_encoder_layers
+ self.num_decoder_layers = num_decoder_layers
+ self.dim_feedforward = dim_feedforward
+ self.dropout_rate = dropout_rate
+ self.num_queries = num_queries
+ self.num_classes = num_classes
+ self.image_size = base.image_size
+ self.input_tensor = input_tensor
+
+ def get_config(self):
+ config = super().get_config()
+ config.update(
+ {
+ "hidden_dim": self.hidden_dim,
+ "num_heads": self.num_heads,
+ "num_encoder_layers": self.num_encoder_layers,
+ "num_decoder_layers": self.num_decoder_layers,
+ "dim_feedforward": self.dim_feedforward,
+ "dropout_rate": self.dropout_rate,
+ "num_queries": self.num_queries,
+ "num_classes": self.num_classes,
+ "image_size": self.image_size,
+ "input_tensor": self.input_tensor,
+ "name": self.name,
+ }
+ )
+ return config
+
+ @classmethod
+ def from_config(cls, config):
+ return cls(**config)
+
+ @classmethod
+ def config_from_hf(cls, hf_config):
+ return {
+ "hidden_dim": hf_config["d_model"],
+ "num_heads": hf_config["encoder_attention_heads"],
+ "num_encoder_layers": hf_config["encoder_layers"],
+ "num_decoder_layers": hf_config["decoder_layers"],
+ "dim_feedforward": hf_config["encoder_ffn_dim"],
+ "dropout_rate": hf_config["dropout"],
+ "num_queries": hf_config["num_queries"],
+ "num_classes": hf_num_classes(hf_config) + 1,
+ }
+
+ @classmethod
+ def transfer_from_hf(cls, keras_model, hf_state_dict):
+ from .convert_table_transformer_hf_to_keras import (
+ transfer_table_transformer_weights,
+ )
+
+ transfer_table_transformer_weights(keras_model, hf_state_dict)