Add Inkling model support (#31681)
Co-authored-by: Chunan Zeng <zcnrex@gmail.com> Co-authored-by: Ke Bao <ispobaoke@gmail.com> Co-authored-by: Yanbin Jiang <jybsuper@gmail.com> Co-authored-by: Yuhao Yang <47235274+yhyang201@users.noreply.github.com> Co-authored-by: Qiaolin Yu <qiaolin.yu@radixark.ai> Co-authored-by: Zhichen Zeng <zczeng@uw.edu> Co-authored-by: Aurick Qiao <aurick@thinkingmachines.ai> Co-authored-by: Joseph <jk@thinkingmachines.ai>
This commit is contained in:
co-authored by
Chunan Zeng
Ke Bao
Yanbin Jiang
Yuhao Yang
Qiaolin Yu
Zhichen Zeng
Aurick Qiao
Joseph
parent
829e9ce9d5
commit
02236fa38c
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "sglang-mm"
|
||||
version = "0.1.0"
|
||||
edition = "2024"
|
||||
description = "Rust-accelerated multimodal preprocessing for SGLang"
|
||||
license = "Apache-2.0"
|
||||
|
||||
[lib]
|
||||
name = "_core"
|
||||
crate-type = ["cdylib"]
|
||||
|
||||
[dependencies]
|
||||
pyo3 = { version = "0.23", features = ["extension-module"] }
|
||||
numpy = "0.23"
|
||||
rayon = "1.10"
|
||||
half = "2.4"
|
||||
image = { version = "0.25", default-features = false, features = ["jpeg", "png"] }
|
||||
blake3 = "1"
|
||||
base64 = "0.22"
|
||||
|
||||
[profile.release]
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
@@ -0,0 +1,120 @@
|
||||
# sglang-mm
|
||||
|
||||
Rust-accelerated multimodal preprocessing for SGLang. Fused image decode,
|
||||
resize, patchify, normalize, and content hash — all parallel and GIL-released.
|
||||
|
||||
Compiled as `sglang.srt.multimodal._core` via setuptools-rust when installing sglang.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── lib.rs # PyO3 module root (_core)
|
||||
├── registry.rs # ImageProcessorSpec trait + ProcessorRegistry
|
||||
├── common/
|
||||
│ ├── mod.rs # thread pool, image decode, SHA256 hash, base64
|
||||
│ ├── resize.rs # PIL-exact Lanczos resize
|
||||
│ └── transforms.rs # reusable primitives: normalize, pad, extract_patches
|
||||
└── <model>/
|
||||
└── mod.rs # model-specific processor
|
||||
```
|
||||
|
||||
## Python API
|
||||
|
||||
```python
|
||||
from sglang.srt.multimodal._core import common, inkling
|
||||
|
||||
# Common (model-agnostic)
|
||||
common.resize_rgb(arr, out_w, out_h)
|
||||
common.scaled_dims(w, h, rescale_frac, rescale_cap)
|
||||
common.image_decode_rgb(bytes) # -> (h, w, ndarray)
|
||||
common.data_hash(bytes) # -> u64 SHA256
|
||||
common.base64_decode(str) # -> bytes
|
||||
|
||||
# Model-specific
|
||||
inkling.preprocess_images(list[bytes], ps, frac, cap) # -> [(h, w, bits, hash), ...]
|
||||
inkling.decode_patchify(bytes, ps, frac, cap)
|
||||
inkling.decode_patchify_batch(list[bytes], ps, frac, cap)
|
||||
inkling.patchify_rgb(arr, patch_size)
|
||||
```
|
||||
|
||||
## Adding a new model
|
||||
|
||||
1. Create `src/<model_name>/mod.rs`:
|
||||
|
||||
```rust
|
||||
use crate::common;
|
||||
use crate::registry::ImageProcessorSpec;
|
||||
use rayon::prelude::*;
|
||||
|
||||
pub struct MyModelProcessor;
|
||||
|
||||
impl ImageProcessorSpec for MyModelProcessor {
|
||||
fn name(&self) -> &'static str {
|
||||
"my_model"
|
||||
}
|
||||
|
||||
fn preprocess_batch(
|
||||
&self,
|
||||
datas: &[Vec<u8>],
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> Result<Vec<(usize, usize, Vec<u16>, u64)>, String> {
|
||||
common::pool().install(|| {
|
||||
datas.par_iter().map(|data| {
|
||||
let hash = common::sha256_u64(data);
|
||||
let (rgb, h, w) = common::decode_rescale(data, rescale_frac, rescale_cap)?;
|
||||
// Use common::transforms::* or model-specific logic
|
||||
let patches = my_patchify(&rgb, h, w, patch_size);
|
||||
Ok((h, w, patches, hash))
|
||||
}).collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
2. Register in `src/registry.rs` `default_registry()`.
|
||||
|
||||
3. Add PyO3 bindings in `src/<model_name>/mod.rs` with a `register()` function.
|
||||
|
||||
4. Wire up in `src/lib.rs`: `mod my_model;` and `my_model::register(m)?;`.
|
||||
|
||||
5. Add Python processor class that calls `from sglang.srt.multimodal._core import my_model`.
|
||||
|
||||
## Available transform primitives (`common::transforms`)
|
||||
|
||||
| Function | Description |
|
||||
|----------|-------------|
|
||||
| `normalize_rgb_f32` | Single-pass `(pixel/255 - mean) / std` |
|
||||
| `pad_to_grid` | Pad HWC image to grid-aligned dimensions |
|
||||
| `extract_patches_hwc` | Reshape padded image into `[N, ph, pw, C]` patches |
|
||||
| `patch_grid` | Compute `(nph, npw)` for given image and patch size |
|
||||
|
||||
## Design notes
|
||||
|
||||
- Thread pool capped at `min(8, cores)`. Override: `SGL_MM_RS_THREADS`.
|
||||
- PNG decode is bit-exact vs PIL; JPEG may differ by ±1 LSB.
|
||||
- Lanczos resize is a bit-exact clone of PIL's fixed-point implementation.
|
||||
|
||||
## Build
|
||||
|
||||
Automatically built when installing sglang:
|
||||
```bash
|
||||
pip install -e "python"
|
||||
```
|
||||
|
||||
Or standalone for development:
|
||||
```bash
|
||||
cd rust/sglang-mm
|
||||
pip install maturin
|
||||
maturin develop --release
|
||||
```
|
||||
|
||||
## Test
|
||||
|
||||
```bash
|
||||
python bench/generate_golden.py # regenerate fixtures
|
||||
pytest bench/test_golden.py # regression tests
|
||||
python bench/bench_parity.py # parity + benchmark
|
||||
```
|
||||
@@ -0,0 +1,161 @@
|
||||
import io
|
||||
import time
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
import sglang.srt.multimodal._core.inkling
|
||||
from sglang.srt.multimodal.inkling.image_processing import (
|
||||
IMAGE_MEAN,
|
||||
IMAGE_STD,
|
||||
PAD_NORM,
|
||||
_encode_image_bytes,
|
||||
_fill_patches_numba,
|
||||
)
|
||||
|
||||
PS = 40
|
||||
|
||||
|
||||
def ref_patchify(arr: np.ndarray) -> torch.Tensor:
|
||||
h, w, _ = arr.shape
|
||||
nph = (h + PS - 1) // PS
|
||||
npw = w // PS + 1
|
||||
patches = np.empty((nph * npw, PS, PS, 3), dtype=np.float32)
|
||||
_fill_patches_numba(arr, PS, patches, IMAGE_MEAN, IMAGE_STD, PAD_NORM)
|
||||
return torch.from_numpy(patches).to(torch.bfloat16)
|
||||
|
||||
|
||||
def rs_patchify(arr: np.ndarray) -> torch.Tensor:
|
||||
h, w, _ = arr.shape
|
||||
nph = (h + PS - 1) // PS
|
||||
npw = w // PS + 1
|
||||
bits = sglang.srt.multimodal._core.inkling.patchify_rgb(arr, PS)
|
||||
return torch.from_numpy(bits).view(torch.bfloat16).reshape(nph * npw, PS, PS, 3)
|
||||
|
||||
|
||||
def rs_decode_patchify(data: bytes) -> torch.Tensor:
|
||||
h, w, bits = sglang.srt.multimodal._core.inkling.decode_patchify(data, PS)
|
||||
nph = (h + PS - 1) // PS
|
||||
npw = w // PS + 1
|
||||
return torch.from_numpy(bits).view(torch.bfloat16).reshape(nph * npw, PS, PS, 3)
|
||||
|
||||
|
||||
def make_photo_like(h: int, w: int, seed: int = 0) -> np.ndarray:
|
||||
rng = np.random.default_rng(seed)
|
||||
yy, xx = np.mgrid[0:h, 0:w]
|
||||
base = np.stack(
|
||||
[
|
||||
127 + 100 * np.sin(yy / 97.0) * np.cos(xx / 131.0),
|
||||
127 + 100 * np.cos(yy / 61.0) * np.sin(xx / 89.0),
|
||||
127 + 100 * np.sin((xx + yy) / 149.0),
|
||||
],
|
||||
axis=-1,
|
||||
)
|
||||
noise = rng.normal(0, 12, (h // 8 + 1, w // 8 + 1, 3))
|
||||
noise = np.kron(noise, np.ones((8, 8, 1)))[:h, :w]
|
||||
return np.clip(base + noise, 0, 255).astype(np.uint8)
|
||||
|
||||
|
||||
def encode(arr: np.ndarray, fmt: str) -> bytes:
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(
|
||||
buf, format=fmt, **({"quality": 90} if fmt == "JPEG" else {})
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def parity_a():
|
||||
print("=== Parity A: patchify from decoded array (expect bit-exact) ===")
|
||||
rng = np.random.default_rng(42)
|
||||
for h, w in [(1080, 1920), (1920, 1080), (40, 40), (37, 53), (720, 1280), (1, 1)]:
|
||||
arr = rng.integers(0, 256, (h, w, 3), dtype=np.uint8)
|
||||
ref, got = ref_patchify(arr), rs_patchify(arr)
|
||||
exact = torch.equal(ref.view(torch.uint16), got.view(torch.uint16))
|
||||
print(f" {h}x{w}: shape {tuple(got.shape)} bit-exact={exact}")
|
||||
assert exact, f"parity A failed at {h}x{w}"
|
||||
|
||||
|
||||
def parity_b():
|
||||
print("=== Parity B: full decode path ===")
|
||||
arr = make_photo_like(1080, 1920)
|
||||
for fmt in ["PNG", "JPEG"]:
|
||||
data = encode(arr, fmt)
|
||||
ref = _encode_image_bytes(
|
||||
data,
|
||||
patch_size=PS,
|
||||
rescale_image_frac=None,
|
||||
rescale_image_max_upscaled_long_edge=None,
|
||||
)
|
||||
got = rs_decode_patchify(data)
|
||||
got2 = got.view(got.shape[0], 1, PS, PS, 3).expand(-1, 2, -1, -1, -1)
|
||||
if torch.equal(
|
||||
ref.contiguous().view(torch.uint16), got2.contiguous().view(torch.uint16)
|
||||
):
|
||||
print(f" {fmt}: bit-exact=True ({len(data)/1e6:.2f}MB)")
|
||||
else:
|
||||
d = (ref.float() - got2.float()).abs()
|
||||
print(
|
||||
f" {fmt}: bit-exact=False max_abs={d.max():.6f} mean_abs={d.mean():.8f} "
|
||||
f"(decoder difference; normalized-feature units)"
|
||||
)
|
||||
|
||||
|
||||
def bench():
|
||||
print("=== Benchmark (1080p, patch_size=40) ===")
|
||||
arr = make_photo_like(1080, 1920)
|
||||
jpeg = encode(arr, "JPEG")
|
||||
n = 30
|
||||
|
||||
_encode_image_bytes(
|
||||
jpeg,
|
||||
patch_size=PS,
|
||||
rescale_image_frac=None,
|
||||
rescale_image_max_upscaled_long_edge=None,
|
||||
)
|
||||
rs_decode_patchify(jpeg)
|
||||
sglang.srt.multimodal._core.inkling.decode_patchify_batch([jpeg] * 5, PS)
|
||||
|
||||
def run(label, fn, iters=n, images_per_call=1):
|
||||
t0, c0 = time.perf_counter(), time.process_time()
|
||||
for _ in range(iters):
|
||||
fn()
|
||||
wall = (time.perf_counter() - t0) / iters / images_per_call * 1e3
|
||||
cpu = (time.process_time() - c0) / iters / images_per_call * 1e3
|
||||
print(f" {label:42} wall {wall:8.2f} ms/img cpu {cpu:8.2f} ms/img")
|
||||
return wall, cpu
|
||||
|
||||
w_py, c_py = run(
|
||||
"python (PIL + numba + bf16 cast)",
|
||||
lambda: _encode_image_bytes(
|
||||
jpeg,
|
||||
patch_size=PS,
|
||||
rescale_image_frac=None,
|
||||
rescale_image_max_upscaled_long_edge=None,
|
||||
),
|
||||
)
|
||||
w_rs, c_rs = run("rust decode_patchify", lambda: rs_decode_patchify(jpeg))
|
||||
w_rb, c_rb = run(
|
||||
"rust decode_patchify_batch (5 imgs/call)",
|
||||
lambda: sglang.srt.multimodal._core.inkling.decode_patchify_batch(
|
||||
[jpeg] * 5, PS
|
||||
),
|
||||
iters=max(n // 5, 5),
|
||||
images_per_call=5,
|
||||
)
|
||||
|
||||
run("python numba patchify only", lambda: ref_patchify(arr))
|
||||
run("rust patchify_rgb only", lambda: rs_patchify(arr))
|
||||
|
||||
print(
|
||||
f"\n speedup vs python: single {w_py / w_rs:.1f}x wall / {c_py / c_rs:.1f}x cpu, "
|
||||
f"batch {w_py / w_rb:.1f}x wall / {c_py / c_rb:.1f}x cpu"
|
||||
)
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
torch.set_num_threads(8)
|
||||
parity_a()
|
||||
parity_b()
|
||||
bench()
|
||||
print("\nOK")
|
||||
@@ -0,0 +1,12 @@
|
||||
[build-system]
|
||||
requires = ["maturin>=1.5,<2"]
|
||||
build-backend = "maturin"
|
||||
|
||||
[project]
|
||||
name = "sglang-mm"
|
||||
version = "0.1.0"
|
||||
description = "Rust-accelerated multimodal preprocessing for SGLang"
|
||||
requires-python = ">=3.9"
|
||||
|
||||
[tool.maturin]
|
||||
module-name = "_core"
|
||||
@@ -0,0 +1,136 @@
|
||||
pub mod resize;
|
||||
pub mod transforms;
|
||||
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray3, PyUntypedArrayMethods};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
|
||||
|
||||
pub fn pool() -> &'static rayon::ThreadPool {
|
||||
static POOL: OnceLock<rayon::ThreadPool> = OnceLock::new();
|
||||
POOL.get_or_init(|| {
|
||||
let n = std::env::var("SGL_MM_RS_THREADS")
|
||||
.ok()
|
||||
.and_then(|s| s.parse::<usize>().ok())
|
||||
.filter(|&n| n > 0)
|
||||
.unwrap_or_else(|| std::thread::available_parallelism().map_or(8, |c| c.get().min(8)));
|
||||
rayon::ThreadPoolBuilder::new()
|
||||
.num_threads(n)
|
||||
.thread_name(|i| format!("sgl-mm-{i}"))
|
||||
.build()
|
||||
.expect("failed to build rayon pool")
|
||||
})
|
||||
}
|
||||
|
||||
pub fn sha256_u64(data: &[u8]) -> u64 {
|
||||
let digest = blake3::hash(data);
|
||||
u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap())
|
||||
}
|
||||
|
||||
pub fn decode_rgb(data: &[u8]) -> Result<(Vec<u8>, usize, usize), String> {
|
||||
let img = image::load_from_memory(data).map_err(|e| format!("image decode: {e}"))?;
|
||||
let rgb = img.to_rgb8();
|
||||
let (w, h) = rgb.dimensions();
|
||||
Ok((rgb.into_raw(), h as usize, w as usize))
|
||||
}
|
||||
|
||||
pub fn decode_rescale(
|
||||
data: &[u8],
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> Result<(Vec<u8>, usize, usize), String> {
|
||||
let (rgb, h, w) = decode_rgb(data)?;
|
||||
let (tw, th) = resize::scaled_dims(w, h, rescale_frac, rescale_cap);
|
||||
if (tw, th) == (w, h) {
|
||||
return Ok((rgb, h, w));
|
||||
}
|
||||
Ok((resize::resize_lanczos_rgb(&rgb, h, w, th, tw), th, tw))
|
||||
}
|
||||
|
||||
// --- Python-exposed functions ---
|
||||
|
||||
#[pyfunction]
|
||||
pub fn resize_rgb<'py>(
|
||||
py: Python<'py>,
|
||||
arr: PyReadonlyArray3<'py, u8>,
|
||||
out_w: usize,
|
||||
out_h: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<u8>>> {
|
||||
if out_w == 0 || out_h == 0 {
|
||||
return Err(PyValueError::new_err("output size must be positive"));
|
||||
}
|
||||
let shape = arr.shape();
|
||||
let (h, w, c) = (shape[0], shape[1], shape[2]);
|
||||
if c != 3 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"expected HWC RGB array with 3 channels, got {c}"
|
||||
)));
|
||||
}
|
||||
let data = arr
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err("array must be C-contiguous"))?
|
||||
.to_vec();
|
||||
let out = py.allow_threads(move || {
|
||||
pool().install(|| resize::resize_lanczos_rgb(&data, h, w, out_h, out_w))
|
||||
});
|
||||
Ok(out.into_pyarray_bound(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (w, h, rescale_frac=None, rescale_cap=None))]
|
||||
pub fn scaled_dims(
|
||||
w: usize,
|
||||
h: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> (usize, usize) {
|
||||
resize::scaled_dims(w, h, rescale_frac, rescale_cap)
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn image_decode_rgb<'py>(
|
||||
py: Python<'py>,
|
||||
data: Vec<u8>,
|
||||
) -> PyResult<(usize, usize, Bound<'py, PyArray1<u8>>)> {
|
||||
let (rgb, h, w) = py
|
||||
.allow_threads(move || decode_rgb(&data))
|
||||
.map_err(PyValueError::new_err)?;
|
||||
Ok((h, w, rgb.into_pyarray_bound(py)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn data_hash(py: Python<'_>, data: Vec<u8>) -> u64 {
|
||||
py.allow_threads(move || {
|
||||
let digest = blake3::hash(&data);
|
||||
u64::from_be_bytes(digest.as_bytes()[..8].try_into().unwrap())
|
||||
})
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
pub fn base64_decode<'py>(
|
||||
py: Python<'py>,
|
||||
encoded: &str,
|
||||
) -> PyResult<Bound<'py, pyo3::types::PyBytes>> {
|
||||
use base64::Engine;
|
||||
let decoded = py
|
||||
.allow_threads(|| {
|
||||
base64::engine::general_purpose::STANDARD
|
||||
.decode(encoded)
|
||||
.map_err(|e| format!("base64 decode error: {e}"))
|
||||
})
|
||||
.map_err(PyValueError::new_err)?;
|
||||
Ok(pyo3::types::PyBytes::new_bound(py, &decoded))
|
||||
}
|
||||
|
||||
pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
let m = PyModule::new_bound(parent.py(), "common")?;
|
||||
m.add_function(wrap_pyfunction!(resize_rgb, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(scaled_dims, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(image_decode_rgb, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(data_hash, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(base64_decode, &m)?)?;
|
||||
parent.add_submodule(&m)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
use rayon::prelude::*;
|
||||
|
||||
const PRECISION_BITS: i32 = 32 - 8 - 2;
|
||||
|
||||
fn sinc(x: f64) -> f64 {
|
||||
if x == 0.0 {
|
||||
return 1.0;
|
||||
}
|
||||
let x = x * std::f64::consts::PI;
|
||||
x.sin() / x
|
||||
}
|
||||
|
||||
fn lanczos(x: f64) -> f64 {
|
||||
if (-3.0..3.0).contains(&x) {
|
||||
sinc(x) * sinc(x / 3.0)
|
||||
} else {
|
||||
0.0
|
||||
}
|
||||
}
|
||||
|
||||
struct Coeffs {
|
||||
bounds: Vec<(usize, usize)>,
|
||||
kk: Vec<i32>,
|
||||
ksize: usize,
|
||||
}
|
||||
|
||||
fn precompute_coeffs(in_size: usize, out_size: usize) -> Coeffs {
|
||||
let scale = in_size as f64 / out_size as f64;
|
||||
let filterscale = if scale < 1.0 { 1.0 } else { scale };
|
||||
let support = 3.0 * filterscale;
|
||||
let ksize = support.ceil() as usize * 2 + 1;
|
||||
let ss = 1.0 / filterscale;
|
||||
|
||||
let mut kkf = vec![0.0f64; out_size * ksize];
|
||||
let mut bounds = vec![(0usize, 0usize); out_size];
|
||||
for xx in 0..out_size {
|
||||
let center = (xx as f64 + 0.5) * scale;
|
||||
let mut xmin = (center - support + 0.5) as i32;
|
||||
if xmin < 0 {
|
||||
xmin = 0;
|
||||
}
|
||||
let mut xmax = (center + support + 0.5) as i32;
|
||||
if xmax > in_size as i32 {
|
||||
xmax = in_size as i32;
|
||||
}
|
||||
let count = (xmax - xmin) as usize;
|
||||
let k = &mut kkf[xx * ksize..(xx + 1) * ksize];
|
||||
let mut ww = 0.0f64;
|
||||
for x in 0..count {
|
||||
let w = lanczos((x as f64 + xmin as f64 - center + 0.5) * ss);
|
||||
k[x] = w;
|
||||
ww += w;
|
||||
}
|
||||
if ww != 0.0 {
|
||||
for x in 0..count {
|
||||
k[x] /= ww;
|
||||
}
|
||||
}
|
||||
bounds[xx] = (xmin as usize, count);
|
||||
}
|
||||
|
||||
let factor = (1i64 << PRECISION_BITS) as f64;
|
||||
let kk = kkf
|
||||
.iter()
|
||||
.map(|&v| {
|
||||
if v < 0.0 {
|
||||
(-0.5 + v * factor) as i32
|
||||
} else {
|
||||
(0.5 + v * factor) as i32
|
||||
}
|
||||
})
|
||||
.collect();
|
||||
Coeffs { bounds, kk, ksize }
|
||||
}
|
||||
|
||||
#[inline]
|
||||
fn clip8(v: i32) -> u8 {
|
||||
if v >= 1 << (PRECISION_BITS + 8) {
|
||||
255
|
||||
} else if v <= 0 {
|
||||
0
|
||||
} else {
|
||||
(v >> PRECISION_BITS) as u8
|
||||
}
|
||||
}
|
||||
|
||||
fn resample_horizontal(src: &[u8], h: usize, w: usize, out_w: usize, c: &Coeffs) -> Vec<u8> {
|
||||
let mut out = vec![0u8; h * out_w * 3];
|
||||
out.par_chunks_mut(out_w * 3)
|
||||
.enumerate()
|
||||
.for_each(|(y, row)| {
|
||||
let src_row = &src[y * w * 3..(y + 1) * w * 3];
|
||||
for xx in 0..out_w {
|
||||
let (xmin, count) = c.bounds[xx];
|
||||
let k = &c.kk[xx * c.ksize..xx * c.ksize + count];
|
||||
let mut s = [1i32 << (PRECISION_BITS - 1); 3];
|
||||
for (x, &coef) in k.iter().enumerate() {
|
||||
let p = (xmin + x) * 3;
|
||||
s[0] += src_row[p] as i32 * coef;
|
||||
s[1] += src_row[p + 1] as i32 * coef;
|
||||
s[2] += src_row[p + 2] as i32 * coef;
|
||||
}
|
||||
let o = xx * 3;
|
||||
row[o] = clip8(s[0]);
|
||||
row[o + 1] = clip8(s[1]);
|
||||
row[o + 2] = clip8(s[2]);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
fn resample_vertical(src: &[u8], w: usize, out_h: usize, c: &Coeffs) -> Vec<u8> {
|
||||
let mut out = vec![0u8; out_h * w * 3];
|
||||
out.par_chunks_mut(w * 3)
|
||||
.enumerate()
|
||||
.for_each(|(yy, row)| {
|
||||
let (ymin, count) = c.bounds[yy];
|
||||
let k = &c.kk[yy * c.ksize..yy * c.ksize + count];
|
||||
for x in 0..w {
|
||||
let mut s = [1i32 << (PRECISION_BITS - 1); 3];
|
||||
for (y, &coef) in k.iter().enumerate() {
|
||||
let p = ((ymin + y) * w + x) * 3;
|
||||
s[0] += src[p] as i32 * coef;
|
||||
s[1] += src[p + 1] as i32 * coef;
|
||||
s[2] += src[p + 2] as i32 * coef;
|
||||
}
|
||||
let o = x * 3;
|
||||
row[o] = clip8(s[0]);
|
||||
row[o + 1] = clip8(s[1]);
|
||||
row[o + 2] = clip8(s[2]);
|
||||
}
|
||||
});
|
||||
out
|
||||
}
|
||||
|
||||
pub fn resize_lanczos_rgb(
|
||||
src: &[u8],
|
||||
h: usize,
|
||||
w: usize,
|
||||
out_h: usize,
|
||||
out_w: usize,
|
||||
) -> Vec<u8> {
|
||||
let need_h = out_w != w;
|
||||
let need_v = out_h != h;
|
||||
if need_h && need_v {
|
||||
let ch = precompute_coeffs(w, out_w);
|
||||
let tmp = resample_horizontal(src, h, w, out_w, &ch);
|
||||
let cv = precompute_coeffs(h, out_h);
|
||||
resample_vertical(&tmp, out_w, out_h, &cv)
|
||||
} else if need_h {
|
||||
let ch = precompute_coeffs(w, out_w);
|
||||
resample_horizontal(src, h, w, out_w, &ch)
|
||||
} else if need_v {
|
||||
let cv = precompute_coeffs(h, out_h);
|
||||
resample_vertical(src, w, out_h, &cv)
|
||||
} else {
|
||||
src.to_vec()
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scaled_dims(
|
||||
w: usize,
|
||||
h: usize,
|
||||
frac: Option<f64>,
|
||||
cap: Option<i64>,
|
||||
) -> (usize, usize) {
|
||||
let Some(frac) = frac else {
|
||||
return (w, h);
|
||||
};
|
||||
let long_edge = w.max(h);
|
||||
if long_edge == 0 {
|
||||
return (w, h);
|
||||
}
|
||||
let mut target = long_edge as f64 * frac;
|
||||
if let Some(cap) = cap {
|
||||
let effective_cap = cap.max(long_edge as i64);
|
||||
target = target.min(effective_cap as f64);
|
||||
}
|
||||
let ratio = target / long_edge as f64;
|
||||
if ratio == 1.0 {
|
||||
return (w, h);
|
||||
}
|
||||
let scale = |v: usize| ((v as f64 * ratio + 0.5).floor() as i64).max(1) as usize;
|
||||
(scale(w), scale(h))
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
//! Reusable image transform primitives.
|
||||
//!
|
||||
//! Model-specific processors compose these to build their preprocessing
|
||||
//! pipelines. All functions operate on flat RGB byte arrays (HWC layout).
|
||||
|
||||
/// Normalize u8 RGB pixels to f32 in a single pass: `(pixel/255 - mean) / std`.
|
||||
///
|
||||
/// Writes into `out` which must have length `h * w * 3`.
|
||||
pub fn normalize_rgb_f32(
|
||||
rgb: &[u8],
|
||||
h: usize,
|
||||
w: usize,
|
||||
mean: &[f32; 3],
|
||||
std: &[f32; 3],
|
||||
out: &mut [f32],
|
||||
) {
|
||||
debug_assert_eq!(rgb.len(), h * w * 3);
|
||||
debug_assert_eq!(out.len(), h * w * 3);
|
||||
let inv255 = 1.0f32 / 255.0;
|
||||
for i in 0..h * w {
|
||||
for c in 0..3 {
|
||||
let raw = rgb[i * 3 + c] as f32 * inv255;
|
||||
out[i * 3 + c] = (raw - mean[c]) / std[c];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Pad an HWC image to a grid-aligned size, filling padded pixels with `pad_value`.
|
||||
///
|
||||
/// Returns the padded buffer and the new (height, width).
|
||||
pub fn pad_to_grid(
|
||||
rgb_f32: &[f32],
|
||||
h: usize,
|
||||
w: usize,
|
||||
channels: usize,
|
||||
grid_h: usize,
|
||||
grid_w: usize,
|
||||
pad_value: &[f32],
|
||||
) -> (Vec<f32>, usize, usize) {
|
||||
let new_h = ((h + grid_h - 1) / grid_h) * grid_h;
|
||||
let new_w = ((w + grid_w - 1) / grid_w) * grid_w;
|
||||
let mut out = vec![0.0f32; new_h * new_w * channels];
|
||||
// Fill with pad value
|
||||
for i in 0..new_h * new_w {
|
||||
for c in 0..channels {
|
||||
out[i * channels + c] = pad_value[c];
|
||||
}
|
||||
}
|
||||
// Copy original data
|
||||
for y in 0..h {
|
||||
let src_start = y * w * channels;
|
||||
let dst_start = y * new_w * channels;
|
||||
out[dst_start..dst_start + w * channels]
|
||||
.copy_from_slice(&rgb_f32[src_start..src_start + w * channels]);
|
||||
}
|
||||
(out, new_h, new_w)
|
||||
}
|
||||
|
||||
/// Reshape a padded HWC image into patches of shape `[num_patches, ph, pw, C]`.
|
||||
///
|
||||
/// `h` and `w` must be divisible by `ph` and `pw` respectively.
|
||||
pub fn extract_patches_hwc(
|
||||
data: &[f32],
|
||||
h: usize,
|
||||
w: usize,
|
||||
channels: usize,
|
||||
ph: usize,
|
||||
pw: usize,
|
||||
) -> Vec<f32> {
|
||||
let nph = h / ph;
|
||||
let npw = w / pw;
|
||||
let patch_size = ph * pw * channels;
|
||||
let mut out = vec![0.0f32; nph * npw * patch_size];
|
||||
for i in 0..nph {
|
||||
for j in 0..npw {
|
||||
let patch_idx = i * npw + j;
|
||||
for y in 0..ph {
|
||||
let src_y = i * ph + y;
|
||||
let src_start = (src_y * w + j * pw) * channels;
|
||||
let dst_start = patch_idx * patch_size + y * pw * channels;
|
||||
out[dst_start..dst_start + pw * channels]
|
||||
.copy_from_slice(&data[src_start..src_start + pw * channels]);
|
||||
}
|
||||
}
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Compute the patch grid dimensions for a given image size and patch size.
|
||||
#[inline]
|
||||
pub fn patch_grid(h: usize, w: usize, patch_h: usize, patch_w: usize) -> (usize, usize) {
|
||||
((h + patch_h - 1) / patch_h, (w + patch_w - 1) / patch_w)
|
||||
}
|
||||
@@ -0,0 +1,287 @@
|
||||
use std::sync::OnceLock;
|
||||
|
||||
use half::bf16;
|
||||
use numpy::{IntoPyArray, PyArray1, PyReadonlyArray3, PyUntypedArrayMethods};
|
||||
use pyo3::exceptions::PyValueError;
|
||||
use pyo3::prelude::*;
|
||||
use rayon::prelude::*;
|
||||
|
||||
use crate::common;
|
||||
|
||||
const MEAN: [f32; 3] = [
|
||||
0.48145466f64 as f32,
|
||||
0.4578275f64 as f32,
|
||||
0.40821073f64 as f32,
|
||||
];
|
||||
const STD: [f32; 3] = [
|
||||
0.26862954f64 as f32,
|
||||
0.2613026f64 as f32,
|
||||
0.2757771f64 as f32,
|
||||
];
|
||||
const INV255: f32 = (1.0f64 / 255.0f64) as f32;
|
||||
const PAD_RAW: f32 = (-1.0f64 / 255.0f64) as f32;
|
||||
|
||||
#[inline]
|
||||
fn pad_bits() -> [u16; 3] {
|
||||
core::array::from_fn(|c| bf16::from_f32((PAD_RAW - MEAN[c]) / STD[c]).to_bits())
|
||||
}
|
||||
|
||||
fn luts() -> &'static [[u16; 256]; 3] {
|
||||
static LUTS: OnceLock<[[u16; 256]; 3]> = OnceLock::new();
|
||||
LUTS.get_or_init(|| {
|
||||
core::array::from_fn(|c| {
|
||||
core::array::from_fn(|v| {
|
||||
let raw = v as u8 as f32 * INV255;
|
||||
bf16::from_f32((raw - MEAN[c]) / STD[c]).to_bits()
|
||||
})
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
#[inline]
|
||||
pub fn grid(h: usize, w: usize, ps: usize) -> (usize, usize) {
|
||||
((h + ps - 1) / ps, w / ps + 1)
|
||||
}
|
||||
|
||||
fn patchify_into(arr: &[u8], h: usize, w: usize, ps: usize, out: &mut [u16]) {
|
||||
let (_nph, npw) = grid(h, w, ps);
|
||||
let pad = pad_bits();
|
||||
let lut = luts();
|
||||
let patch_elems = ps * ps * 3;
|
||||
let row_elems = npw * patch_elems;
|
||||
|
||||
let body = |(i, row): (usize, &mut [u16])| {
|
||||
let y_base = i * ps;
|
||||
for j in 0..npw {
|
||||
let x_base = j * ps;
|
||||
let chunk = &mut row[j * patch_elems..(j + 1) * patch_elems];
|
||||
for y in 0..ps {
|
||||
let iy = y_base + y;
|
||||
if iy >= h {
|
||||
for x in 0..ps {
|
||||
let o = (y * ps + x) * 3;
|
||||
chunk[o..o + 3].copy_from_slice(&pad);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let n_real = if x_base < w { (w - x_base).min(ps) } else { 0 };
|
||||
let src = (iy * w + x_base) * 3;
|
||||
for x in 0..n_real {
|
||||
let o = (y * ps + x) * 3;
|
||||
let p = src + x * 3;
|
||||
chunk[o] = lut[0][arr[p] as usize];
|
||||
chunk[o + 1] = lut[1][arr[p + 1] as usize];
|
||||
chunk[o + 2] = lut[2][arr[p + 2] as usize];
|
||||
}
|
||||
for x in n_real..ps {
|
||||
let o = (y * ps + x) * 3;
|
||||
chunk[o..o + 3].copy_from_slice(&pad);
|
||||
}
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
common::pool().install(|| {
|
||||
out.par_chunks_mut(row_elems).enumerate().for_each(body);
|
||||
});
|
||||
}
|
||||
|
||||
fn patchify_alloc(arr: &[u8], h: usize, w: usize, ps: usize) -> Vec<u16> {
|
||||
let (nph, npw) = grid(h, w, ps);
|
||||
let mut out = vec![0u16; nph * npw * ps * ps * 3];
|
||||
patchify_into(arr, h, w, ps, &mut out);
|
||||
out
|
||||
}
|
||||
|
||||
fn check_ps(ps: usize) -> PyResult<()> {
|
||||
if ps == 0 {
|
||||
return Err(PyValueError::new_err("patch_size must be greater than zero"));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
fn patchify_rgb<'py>(
|
||||
py: Python<'py>,
|
||||
arr: PyReadonlyArray3<'py, u8>,
|
||||
patch_size: usize,
|
||||
) -> PyResult<Bound<'py, PyArray1<u16>>> {
|
||||
check_ps(patch_size)?;
|
||||
let shape = arr.shape();
|
||||
let (h, w, c) = (shape[0], shape[1], shape[2]);
|
||||
if c != 3 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"expected HWC RGB array with 3 channels, got {c}"
|
||||
)));
|
||||
}
|
||||
let data = arr
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err("array must be C-contiguous"))?
|
||||
.to_vec();
|
||||
let out = py.allow_threads(move || patchify_alloc(&data, h, w, patch_size));
|
||||
Ok(out.into_pyarray_bound(py))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (data, patch_size, rescale_frac=None, rescale_cap=None))]
|
||||
fn decode_patchify<'py>(
|
||||
py: Python<'py>,
|
||||
data: Vec<u8>,
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> PyResult<(usize, usize, Bound<'py, PyArray1<u16>>)> {
|
||||
check_ps(patch_size)?;
|
||||
let (h, w, out) = py
|
||||
.allow_threads(move || {
|
||||
common::pool().install(|| {
|
||||
let (rgb, h, w) = common::decode_rescale(&data, rescale_frac, rescale_cap)?;
|
||||
Ok::<_, String>((h, w, patchify_alloc(&rgb, h, w, patch_size)))
|
||||
})
|
||||
})
|
||||
.map_err(PyValueError::new_err)?;
|
||||
Ok((h, w, out.into_pyarray_bound(py)))
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (datas, patch_size, rescale_frac=None, rescale_cap=None))]
|
||||
fn decode_patchify_batch<'py>(
|
||||
py: Python<'py>,
|
||||
datas: Vec<Vec<u8>>,
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> PyResult<Vec<(usize, usize, Bound<'py, PyArray1<u16>>)>> {
|
||||
check_ps(patch_size)?;
|
||||
let results: Vec<Result<(usize, usize, Vec<u16>), String>> =
|
||||
py.allow_threads(move || {
|
||||
common::pool().install(|| {
|
||||
datas
|
||||
.par_iter()
|
||||
.map(|data| {
|
||||
let (rgb, h, w) = common::decode_rescale(data, rescale_frac, rescale_cap)?;
|
||||
Ok((h, w, patchify_alloc(&rgb, h, w, patch_size)))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (h, w, v) = r.map_err(PyValueError::new_err)?;
|
||||
Ok((h, w, v.into_pyarray_bound(py)))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (datas, patch_size, rescale_frac=None, rescale_cap=None))]
|
||||
fn preprocess_images<'py>(
|
||||
py: Python<'py>,
|
||||
datas: Vec<Vec<u8>>,
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> PyResult<Vec<(usize, usize, Bound<'py, PyArray1<u16>>, u64)>> {
|
||||
check_ps(patch_size)?;
|
||||
let results: Vec<Result<(usize, usize, Vec<u16>, u64), String>> =
|
||||
py.allow_threads(move || {
|
||||
common::pool().install(|| {
|
||||
datas
|
||||
.par_iter()
|
||||
.map(|data| {
|
||||
let hash = common::sha256_u64(data);
|
||||
let (rgb, h, w) = common::decode_rescale(data, rescale_frac, rescale_cap)?;
|
||||
Ok((h, w, patchify_alloc(&rgb, h, w, patch_size), hash))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
});
|
||||
results
|
||||
.into_iter()
|
||||
.map(|r| {
|
||||
let (h, w, v, hash) = r.map_err(PyValueError::new_err)?;
|
||||
Ok((h, w, v.into_pyarray_bound(py), hash))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Struct implementing ImageProcessorSpec for Inkling.
|
||||
pub struct InklingProcessor;
|
||||
|
||||
impl crate::registry::ImageProcessorSpec for InklingProcessor {
|
||||
fn name(&self) -> &'static str {
|
||||
"inkling"
|
||||
}
|
||||
|
||||
fn preprocess_batch(
|
||||
&self,
|
||||
datas: &[Vec<u8>],
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> Result<Vec<(usize, usize, Vec<u16>, u64)>, String> {
|
||||
if patch_size == 0 {
|
||||
return Err("patch_size must be greater than zero".into());
|
||||
}
|
||||
common::pool().install(|| {
|
||||
datas
|
||||
.par_iter()
|
||||
.map(|data| {
|
||||
let hash = common::sha256_u64(data);
|
||||
let (rgb, h, w) = common::decode_rescale(data, rescale_frac, rescale_cap)?;
|
||||
Ok((h, w, patchify_alloc(&rgb, h, w, patch_size), hash))
|
||||
})
|
||||
.collect()
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
#[pyfunction]
|
||||
#[pyo3(signature = (arr, raw_bytes, patch_size, rescale_frac=None, rescale_cap=None))]
|
||||
fn rescale_patchify_hash<'py>(
|
||||
py: Python<'py>,
|
||||
arr: PyReadonlyArray3<'py, u8>,
|
||||
raw_bytes: &[u8],
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> PyResult<(usize, usize, Bound<'py, PyArray1<u16>>, u64)> {
|
||||
check_ps(patch_size)?;
|
||||
let shape = arr.shape();
|
||||
let (h, w, c) = (shape[0], shape[1], shape[2]);
|
||||
if c != 3 {
|
||||
return Err(PyValueError::new_err(format!(
|
||||
"expected HWC RGB array with 3 channels, got {c}"
|
||||
)));
|
||||
}
|
||||
let hash = common::sha256_u64(raw_bytes);
|
||||
let rgb = arr
|
||||
.as_slice()
|
||||
.map_err(|_| PyValueError::new_err("array must be C-contiguous"))?
|
||||
.to_vec();
|
||||
let (oh, ow, out) = py.allow_threads(move || {
|
||||
common::pool().install(|| {
|
||||
let (tw, th) = common::resize::scaled_dims(w, h, rescale_frac, rescale_cap);
|
||||
let (rgb, h, w) = if (tw, th) != (w, h) {
|
||||
(common::resize::resize_lanczos_rgb(&rgb, h, w, th, tw), th, tw)
|
||||
} else {
|
||||
(rgb, h, w)
|
||||
};
|
||||
(h, w, patchify_alloc(&rgb, h, w, patch_size))
|
||||
})
|
||||
});
|
||||
Ok((oh, ow, out.into_pyarray_bound(py), hash))
|
||||
}
|
||||
|
||||
pub fn register(parent: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
let m = PyModule::new_bound(parent.py(), "inkling")?;
|
||||
m.add_function(wrap_pyfunction!(patchify_rgb, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(decode_patchify, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(decode_patchify_batch, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(preprocess_images, &m)?)?;
|
||||
m.add_function(wrap_pyfunction!(rescale_patchify_hash, &m)?)?;
|
||||
parent.add_submodule(&m)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
mod common;
|
||||
mod inkling;
|
||||
pub mod registry;
|
||||
|
||||
use pyo3::prelude::*;
|
||||
|
||||
#[pymodule]
|
||||
fn _core(m: &Bound<'_, PyModule>) -> PyResult<()> {
|
||||
common::register(m)?;
|
||||
inkling::register(m)?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Model processor registry.
|
||||
//!
|
||||
//! Each model implements `ImageProcessorSpec` and registers itself. The Python
|
||||
//! layer looks up a processor by model name at init time.
|
||||
|
||||
use pyo3::prelude::*;
|
||||
use pyo3::exceptions::PyValueError;
|
||||
|
||||
/// Trait that each model's image processor must implement.
|
||||
pub trait ImageProcessorSpec: Send + Sync {
|
||||
/// Short identifier, e.g. "inkling".
|
||||
fn name(&self) -> &'static str;
|
||||
|
||||
/// Process a batch of raw image bytes: decode + preprocess + hash.
|
||||
///
|
||||
/// Returns `(height, width, patches_as_u16_bits, content_hash)` per image.
|
||||
fn preprocess_batch(
|
||||
&self,
|
||||
datas: &[Vec<u8>],
|
||||
patch_size: usize,
|
||||
rescale_frac: Option<f64>,
|
||||
rescale_cap: Option<i64>,
|
||||
) -> Result<Vec<(usize, usize, Vec<u16>, u64)>, String>;
|
||||
}
|
||||
|
||||
/// Global registry of available processors.
|
||||
pub struct ProcessorRegistry {
|
||||
specs: Vec<Box<dyn ImageProcessorSpec>>,
|
||||
}
|
||||
|
||||
impl ProcessorRegistry {
|
||||
pub fn new() -> Self {
|
||||
Self { specs: Vec::new() }
|
||||
}
|
||||
|
||||
pub fn register(&mut self, spec: Box<dyn ImageProcessorSpec>) {
|
||||
self.specs.push(spec);
|
||||
}
|
||||
|
||||
pub fn lookup(&self, name: &str) -> Option<&dyn ImageProcessorSpec> {
|
||||
self.specs.iter().find(|s| s.name() == name).map(|s| s.as_ref())
|
||||
}
|
||||
|
||||
pub fn list_names(&self) -> Vec<&'static str> {
|
||||
self.specs.iter().map(|s| s.name()).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Build the default registry with all compiled-in processors.
|
||||
pub fn default_registry() -> ProcessorRegistry {
|
||||
let mut reg = ProcessorRegistry::new();
|
||||
reg.register(Box::new(crate::inkling::InklingProcessor));
|
||||
reg
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from bench_parity import PS, make_photo_like, ref_patchify
|
||||
|
||||
OUT = (
|
||||
sys.argv[1]
|
||||
if len(sys.argv) > 1
|
||||
else os.path.join(os.path.dirname(__file__), "..", "tests", "golden")
|
||||
)
|
||||
|
||||
CASES = [
|
||||
("480x640", 480, 640, 10),
|
||||
("200x320", 200, 320, 11),
|
||||
("37x53", 37, 53, 12),
|
||||
("40x40", 40, 40, 13),
|
||||
]
|
||||
|
||||
os.makedirs(OUT, exist_ok=True)
|
||||
for name, h, w, seed in CASES:
|
||||
arr = make_photo_like(h, w, seed=seed)
|
||||
bits = ref_patchify(arr).view(torch.uint16).numpy()
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="PNG")
|
||||
path = os.path.join(OUT, f"golden_{name}.npz")
|
||||
np.savez_compressed(
|
||||
path,
|
||||
arr=arr,
|
||||
bits=bits,
|
||||
png=np.frombuffer(buf.getvalue(), dtype=np.uint8),
|
||||
patch_size=np.int64(PS),
|
||||
)
|
||||
print(f" {path}: input {h}x{w}, bits {bits.shape}")
|
||||
print("GOLDEN_OK")
|
||||
@@ -0,0 +1,51 @@
|
||||
import glob
|
||||
import os
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
|
||||
import sglang.srt.multimodal._core.inkling
|
||||
|
||||
GOLDEN_DIR = os.environ.get(
|
||||
"INKLING_MM_GOLDEN_DIR",
|
||||
os.path.join(os.path.dirname(__file__), "..", "tests", "golden"),
|
||||
)
|
||||
GOLDENS = sorted(glob.glob(os.path.join(GOLDEN_DIR, "golden_*.npz")))
|
||||
|
||||
|
||||
def bf16_bits_to_f32(bits: np.ndarray) -> np.ndarray:
|
||||
return (bits.astype(np.uint32) << 16).view(np.float32)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", GOLDENS, ids=[os.path.basename(p) for p in GOLDENS])
|
||||
def test_patchify_rgb_bit_exact(path):
|
||||
g = np.load(path)
|
||||
got = sglang.srt.multimodal._core.inkling.patchify_rgb(
|
||||
g["arr"], int(g["patch_size"])
|
||||
)
|
||||
np.testing.assert_array_equal(got, g["bits"].reshape(-1))
|
||||
|
||||
|
||||
@pytest.mark.parametrize("path", GOLDENS, ids=[os.path.basename(p) for p in GOLDENS])
|
||||
def test_decode_patchify_png_bit_exact(path):
|
||||
g = np.load(path)
|
||||
h_ref, w_ref = g["arr"].shape[:2]
|
||||
h, w, got = sglang.srt.multimodal._core.inkling.decode_patchify(
|
||||
g["png"].tobytes(), int(g["patch_size"])
|
||||
)
|
||||
assert (h, w) == (h_ref, w_ref)
|
||||
np.testing.assert_array_equal(got, g["bits"].reshape(-1))
|
||||
|
||||
|
||||
def test_batch_matches_single():
|
||||
gs = [np.load(p) for p in GOLDENS]
|
||||
data = [g["png"].tobytes() for g in gs]
|
||||
ps = int(gs[0]["patch_size"])
|
||||
for (h, w, bits), g in zip(
|
||||
sglang.srt.multimodal._core.inkling.decode_patchify_batch(data, ps), gs
|
||||
):
|
||||
np.testing.assert_array_equal(bits, g["bits"].reshape(-1))
|
||||
|
||||
|
||||
def test_golden_fixtures_exist():
|
||||
assert len(GOLDENS) >= 4, f"expected golden fixtures in {GOLDEN_DIR}"
|
||||
@@ -0,0 +1,103 @@
|
||||
import asyncio
|
||||
import base64
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
import time
|
||||
from types import SimpleNamespace
|
||||
|
||||
import numpy as np
|
||||
import soundfile as sf
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from bench_parity import make_photo_like
|
||||
|
||||
from sglang.srt.managers.mm_utils import data_hash, hash_feature
|
||||
from sglang.srt.multimodal.inkling import InklingProcessor
|
||||
from sglang.srt.multimodal.processors import inkling as prc
|
||||
|
||||
|
||||
def png_bytes(arr):
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="PNG")
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def wav_bytes(seconds=1.0, sr=16000):
|
||||
t = np.linspace(0, seconds, int(sr * seconds), endpoint=False)
|
||||
buf = io.BytesIO()
|
||||
sf.write(
|
||||
buf, (0.3 * np.sin(2 * np.pi * 440 * t)).astype(np.float32), sr, format="WAV"
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def make_proc():
|
||||
proc = prc.InklingMultimodalProcessor.__new__(prc.InklingMultimodalProcessor)
|
||||
proc.IMAGE_TOKEN_ID = 100
|
||||
proc.AUDIO_TOKEN_ID = 101
|
||||
proc.AUDIO_END_TOKEN_ID = 102
|
||||
proc.inkling_processor = InklingProcessor()
|
||||
return proc
|
||||
|
||||
|
||||
proc = make_proc()
|
||||
img = png_bytes(make_photo_like(200, 320, seed=7))
|
||||
aud = wav_bytes()
|
||||
|
||||
out = proc.assemble([1, 100, 2, 101, 3], [img], [aud])
|
||||
img_item = next(i for i in out.mm_items if i.modality.name == "IMAGE")
|
||||
aud_item = next(i for i in out.mm_items if i.modality.name == "AUDIO")
|
||||
assert img_item.hash == data_hash(img), "image hash != data_hash(raw bytes)"
|
||||
assert aud_item.hash == data_hash(aud), "audio hash != data_hash(raw bytes)"
|
||||
print(f" assemble: image hash={img_item.hash:#x} audio hash={aud_item.hash:#x} OK")
|
||||
|
||||
h0 = img_item.hash
|
||||
img_item.set_pad_value()
|
||||
assert img_item.hash == h0 and img_item.pad_value is not None
|
||||
print(f" set_pad_value: hash preserved, pad_value={img_item.pad_value} OK")
|
||||
|
||||
out2 = proc.assemble([1, 100, 2], [img], [])
|
||||
assert out2.mm_items[0].hash == h0
|
||||
print(" determinism: same bytes -> same hash OK")
|
||||
|
||||
data_url = "data:image/png;base64," + base64.b64encode(img).decode()
|
||||
req = SimpleNamespace(input_ids=[1, 100, 100, 2])
|
||||
out3 = asyncio.run(
|
||||
proc.process_mm_data_async(
|
||||
image_data=[data_url, data_url], audio_data=None, request_obj=req
|
||||
)
|
||||
)
|
||||
assert all(i.hash == h0 for i in out3.mm_items), "data: URL roundtrip hash mismatch"
|
||||
print(" process_mm_data_async: concurrent resolve + hash OK")
|
||||
|
||||
orig = prc._resolve_media_item
|
||||
prc._resolve_media_item = lambda it: (time.sleep(0.3), orig(it))[1]
|
||||
t0 = time.perf_counter()
|
||||
asyncio.run(prc._resolve_media_items([data_url] * 8))
|
||||
elapsed = time.perf_counter() - t0
|
||||
prc._resolve_media_item = orig
|
||||
assert elapsed < 1.2, f"8x 0.3s resolves took {elapsed:.2f}s; expected ~0.3s"
|
||||
print(f" concurrency: 8 x 0.3s resolves in {elapsed:.2f}s OK")
|
||||
|
||||
imgs_5 = [png_bytes(make_photo_like(1080, 1920, seed=s)) for s in range(5)]
|
||||
feats = [
|
||||
torch.randn(1323, 1, 40, 40, 3, dtype=torch.bfloat16).expand(1323, 2, 40, 40, 3)
|
||||
for _ in range(5)
|
||||
]
|
||||
t0 = time.perf_counter()
|
||||
for b in imgs_5:
|
||||
data_hash(b)
|
||||
t_bytes = (time.perf_counter() - t0) * 1e3
|
||||
t0 = time.perf_counter()
|
||||
for f in feats:
|
||||
hash_feature(f)
|
||||
t_feat = (time.perf_counter() - t0) * 1e3
|
||||
print(
|
||||
f" hash cost 5 imgs: raw bytes {t_bytes:.1f}ms vs feature tensor {t_feat:.1f}ms "
|
||||
f"({t_feat / t_bytes:.0f}x)"
|
||||
)
|
||||
|
||||
print("HASH_FETCH_OK")
|
||||
@@ -0,0 +1,86 @@
|
||||
import io
|
||||
import os
|
||||
import sys
|
||||
|
||||
import numpy as np
|
||||
import torch
|
||||
from PIL import Image
|
||||
|
||||
sys.path.insert(0, os.path.dirname(__file__))
|
||||
from bench_parity import make_photo_like
|
||||
|
||||
import sglang.srt.multimodal.inkling.image_processing as ip
|
||||
|
||||
|
||||
def encode(arr, fmt):
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(
|
||||
buf, format=fmt, **({"quality": 90} if fmt == "JPEG" else {})
|
||||
)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def run(images, use_rs: bool, rescale: bool):
|
||||
ip._rs_module = None
|
||||
os.environ["SGLANG_RS_MM_PREPROCESS"] = "1" if use_rs else "0"
|
||||
kwargs = (
|
||||
{}
|
||||
if rescale
|
||||
else {"rescale_image_frac": None, "rescale_image_max_upscaled_long_edge": None}
|
||||
)
|
||||
proc = ip.InklingImageProcessor(patch_size=40, **kwargs)
|
||||
out = proc.preprocess(images)
|
||||
assert (ip._rs_module is not False) == use_rs, "rust module gating mismatch"
|
||||
return out
|
||||
|
||||
|
||||
def compare(tag, images, expect_exact, rescale=False):
|
||||
ref = run(images, use_rs=False, rescale=rescale)
|
||||
got = run(images, use_rs=True, rescale=rescale)
|
||||
assert ref["num_patches"] == got["num_patches"], tag
|
||||
assert ref["num_tokens"] == got["num_tokens"], tag
|
||||
a, b = ref["vision_patches_bthwc"], got["vision_patches_bthwc"]
|
||||
assert a.shape == b.shape and a.dtype == b.dtype, f"{tag}: {a.shape} vs {b.shape}"
|
||||
exact = torch.equal(
|
||||
a.contiguous().view(torch.uint16), b.contiguous().view(torch.uint16)
|
||||
)
|
||||
if expect_exact:
|
||||
assert exact, f"{tag}: expected bit-exact"
|
||||
print(f" {tag}: bit-exact=True shape={tuple(a.shape)}")
|
||||
else:
|
||||
d = (a.float() - b.float()).abs()
|
||||
print(
|
||||
f" {tag}: bit-exact={exact} max_abs={d.max():.6f} shape={tuple(a.shape)}"
|
||||
)
|
||||
assert d.max() < 0.25, f"{tag}: JPEG decoder diff too large"
|
||||
|
||||
|
||||
arr1 = make_photo_like(1080, 1920, seed=1)
|
||||
arr2 = make_photo_like(720, 1280, seed=2)
|
||||
arr3 = make_photo_like(480, 640, seed=3)
|
||||
|
||||
print("=== integration: InklingImageProcessor env-gated rust path ===")
|
||||
compare("single PNG", [encode(arr1, "PNG")], expect_exact=True)
|
||||
compare("single JPEG", [encode(arr1, "JPEG")], expect_exact=False)
|
||||
compare(
|
||||
"5x PNG batch",
|
||||
[encode(a, "PNG") for a in [arr1, arr2, arr3, arr1, arr2]],
|
||||
expect_exact=True,
|
||||
)
|
||||
compare(
|
||||
"mixed JPEG/PNG batch",
|
||||
[encode(arr1, "JPEG"), encode(arr2, "PNG")],
|
||||
expect_exact=False,
|
||||
)
|
||||
compare("PIL input (PNG roundtrip)", [Image.fromarray(arr3)], expect_exact=True)
|
||||
compare("single PNG rescaled", [encode(arr1, "PNG")], expect_exact=True, rescale=True)
|
||||
compare(
|
||||
"single JPEG rescaled", [encode(arr1, "JPEG")], expect_exact=False, rescale=True
|
||||
)
|
||||
compare(
|
||||
"3x mixed rescaled",
|
||||
[encode(arr1, "JPEG"), encode(arr2, "PNG"), encode(arr3, "PNG")],
|
||||
expect_exact=False,
|
||||
rescale=True,
|
||||
)
|
||||
print("INTEGRATION_OK")
|
||||
@@ -0,0 +1,121 @@
|
||||
import math
|
||||
import time
|
||||
from typing import Optional
|
||||
|
||||
import numpy as np
|
||||
import pytest
|
||||
from PIL import Image
|
||||
|
||||
import sglang.srt.multimodal._core.inkling
|
||||
|
||||
|
||||
def py_scaled_dims(
|
||||
width: int,
|
||||
height: int,
|
||||
frac: Optional[float],
|
||||
cap: Optional[int],
|
||||
):
|
||||
if frac is None:
|
||||
return width, height
|
||||
long_edge = max(width, height)
|
||||
if long_edge == 0:
|
||||
return width, height
|
||||
target = float(long_edge) * frac
|
||||
if cap is not None:
|
||||
target = min(target, float(max(cap, long_edge)))
|
||||
ratio = target / float(long_edge)
|
||||
if ratio == 1.0:
|
||||
return width, height
|
||||
|
||||
def scale(value):
|
||||
return max(1, math.floor(float(value) * ratio + 0.5))
|
||||
|
||||
return scale(width), scale(height)
|
||||
|
||||
|
||||
def pil_resize(arr: np.ndarray, tw: int, th: int) -> np.ndarray:
|
||||
return np.array(
|
||||
Image.fromarray(arr).resize((tw, th), resample=Image.Resampling.LANCZOS),
|
||||
dtype=np.uint8,
|
||||
)
|
||||
|
||||
|
||||
def rs_resize(arr: np.ndarray, tw: int, th: int) -> np.ndarray:
|
||||
return sglang.srt.multimodal._core.inkling.resize_rgb(arr, tw, th).reshape(
|
||||
th, tw, 3
|
||||
)
|
||||
|
||||
|
||||
CASES = [
|
||||
(1080, 1920, 1152, 2048),
|
||||
(896, 896, 1792, 1792),
|
||||
(360, 640, 720, 1280),
|
||||
(37, 53, 74, 106),
|
||||
(100, 100, 173, 173),
|
||||
(1, 1, 2, 2),
|
||||
(256, 256, 100, 100),
|
||||
(720, 1280, 720, 1280),
|
||||
(3, 500, 6, 1000),
|
||||
]
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"h,w,th,tw", CASES, ids=[f"{h}x{w}->{th}x{tw}" for h, w, th, tw in CASES]
|
||||
)
|
||||
def test_resize_bit_exact(h, w, th, tw):
|
||||
rng = np.random.default_rng(h * 10000 + w)
|
||||
arr = rng.integers(0, 256, (h, w, 3), dtype=np.uint8)
|
||||
np.testing.assert_array_equal(rs_resize(arr, tw, th), pil_resize(arr, tw, th))
|
||||
|
||||
|
||||
def test_scaled_dims_sweep():
|
||||
rng = np.random.default_rng(0)
|
||||
sizes = [(int(a), int(b)) for a, b in rng.integers(1, 5000, (500, 2))]
|
||||
sizes += [(2048, 1024), (2049, 100), (1024, 2048), (1, 1), (4096, 4096)]
|
||||
for frac, cap in [(2.0, 2048), (1.5, 2048), (3.0, None), (None, None), (2.0, 1)]:
|
||||
for w, h in sizes:
|
||||
assert sglang.srt.multimodal._core.inkling.scaled_dims(
|
||||
w, h, frac, cap
|
||||
) == py_scaled_dims(w, h, frac, cap), (
|
||||
w,
|
||||
h,
|
||||
frac,
|
||||
cap,
|
||||
)
|
||||
|
||||
|
||||
def test_decode_patchify_rescaled_matches_pil_pipeline():
|
||||
import io
|
||||
|
||||
import torch
|
||||
|
||||
rng = np.random.default_rng(7)
|
||||
arr = rng.integers(0, 256, (1080, 1920, 3), dtype=np.uint8)
|
||||
buf = io.BytesIO()
|
||||
Image.fromarray(arr).save(buf, format="PNG")
|
||||
h, w, bits = sglang.srt.multimodal._core.inkling.decode_patchify(
|
||||
buf.getvalue(), 40, 2.0, 2048
|
||||
)
|
||||
assert (w, h) == py_scaled_dims(1920, 1080, 2.0, 2048)
|
||||
ref_arr = pil_resize(arr, w, h)
|
||||
ref_bits = sglang.srt.multimodal._core.inkling.patchify_rgb(ref_arr, 40)
|
||||
np.testing.assert_array_equal(bits, ref_bits)
|
||||
assert torch.from_numpy(bits).view(torch.bfloat16).shape[0] > 0
|
||||
|
||||
|
||||
def test_resize_bench():
|
||||
arr = np.random.default_rng(1).integers(0, 256, (1080, 1920, 3), dtype=np.uint8)
|
||||
tw, th = py_scaled_dims(1920, 1080, 2.0, 2048)
|
||||
pil_resize(arr, tw, th)
|
||||
rs_resize(arr, tw, th)
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(10):
|
||||
pil_resize(arr, tw, th)
|
||||
t_pil = (time.perf_counter() - t0) / 10 * 1e3
|
||||
t0 = time.perf_counter()
|
||||
for _ in range(10):
|
||||
rs_resize(arr, tw, th)
|
||||
t_rs = (time.perf_counter() - t0) / 10 * 1e3
|
||||
print(
|
||||
f"\nresize 1920x1080->{tw}x{th}: PIL {t_pil:.1f}ms rust {t_rs:.1f}ms ({t_pil/t_rs:.1f}x)"
|
||||
)
|
||||
Reference in New Issue
Block a user