Skip to content

fix(multimodal): composite transparent images for Kimi-K3 - #1991

Closed
key4ng wants to merge 2 commits into
mainfrom
fix/kimi-k3-transparent-bg
Closed

fix(multimodal): composite transparent images for Kimi-K3#1991
key4ng wants to merge 2 commits into
mainfrom
fix/kimi-k3-transparent-bg

Conversation

@key4ng

@key4ng key4ng commented Jul 28, 2026

Copy link
Copy Markdown
Member

Description

Problem

Kimi-K3's reference vision processor composites alpha-carrying images over a configured background before the NaViT resize and patchify (kimi_k3_vision_processing.py#L110-L114):

item = ensure_media_type(
    item,
    transparent_bg_config=self._transparent_bg_config,
    transparent_bg_fill_stage=self._transparent_bg_fill_stage,
)
resize_config = self.get_resize_config(item)

SMG registered kimi-k3 against KimiK25Processor, which has no such step — it drops the alpha channel via to_rgb8(). Dropping alpha is not the same operation as compositing over a background. A fully transparent PNG pixel usually stores RGB (0,0,0) beneath its alpha, so dropping alpha normalizes it to -1.0 — solid black — where the model was trained to see the chessboard the checkpoint describes.

Anything served as RGBA reached the encoder with its background inverted: logos, icons, diagrams exported with transparency, screenshots with rounded corners. Opaque JPEG/PNG traffic was unaffected, which is why MMMU and OCRBench did not surface it.

Only the gRPC worker path is affected. On the HTTP/OpenAI proxy path the engine preprocesses, so SMG's Rust never runs.

Solution

Two commits, so the refactor is reviewable separately from the behavior change.

1. refactor(multimodal) — a provable no-op. K2.5 and K3 share the MoonViT stack and differ only in parameters, but there was nowhere to express a K3-specific difference. The reference factors its NaViT helpers into a shared media_utils module with a thin per-model processor class on top; this mirrors that split using the shape qwen_vl_base already provides for the Qwen VL family. A test asserts both processors still produce byte-identical output.

2. fix(multimodal) — the compositing step. All values come from the checkpoint, never hardcoded, matching the reference's fallback to dropping alpha when transparent_bg_config is absent. Hardcoded checkpoint values are the bug class that caused this incident in the first place.

The fill stage is load-bearing, not cosmetic: a chessboard is generated at the resolution of the image it lands on, so before_resize and after_resize yield different square sizes relative to the content. K3's checkpoint ships after_resize.

Changes

  • processors/moonvit.rs (new): MoonVitProcessorBase + MoonVitConfig hold the shared resize → pad → normalize → patchify pipeline, moved verbatim out of kimi_k25.rs.
  • processors/kimi_k25.rs: reduced to a thin wrapper that Derefs to the base. Declares transparent_bg: None — its reference class has no transparency handling at all, so a config carrying the keys must not change its output.
  • processors/kimi_k3.rs (new): second thin wrapper, model_name: "kimi-k3", resolving the checkpoint's transparency settings per request.
  • transforms.rs: TransparentBgConfig / TransparentBgPattern / TransparentBgFillStage deserialize straight from a preprocessor_config.json; fill_transparent_bg blends a*img + (1-a)*bg with the reference's truncating uint8 cast.
  • transforms.rs: resize_straight_alpha. Our SIMD resizer premultiplies by alpha by default, dragging RGB toward zero wherever alpha is partial. Pillow convolves channels independently, so compositing after a resize requires the non-premultiplied variant.
  • preprocessor_config.rs: lifts transparent_bg_config / transparent_bg_fill_stage out of media_proc_cfg; transparent_bg() returns them together, since the stage is only meaningful alongside a config.
  • processor.rs: kimi-k3 / kimi_k3 now resolve to KimiK3Processor.

Test Plan

cargo test -p llm-multimodal — 274 lib tests + all integration suites (incl. 81 golden tests) pass, 0 failures. 12 new tests:

Test Asserts
opaque_images_match_k25_byte_for_byte commit 1 is a no-op
transparent_pixels_composite_over_the_configured_background the fix. Fully transparent input over a white background normalizes to +1.0; before this PR it was -1.0
transparency_config_does_not_reach_k25 K2.5 still drops alpha given the same config
opaque_images_ignore_the_transparency_config a declared background does not perturb opaque images
fill_stage_paints_the_board_at_its_own_resolution before_resizeafter_resize under a 4× downscale
chessboard_background_matches_reference every pixel of a 24×24 board matches _create_chessboard_background, both square_on_top_left phases
fill_transparent_bg_blends_partial_alpha exact float32 arithmetic + truncating cast
straight_alpha_resize_keeps_rgba_and_ignores_premultiply RGB under fully transparent pixels survives the resize
test_parse_kimi_k3_transparency_settings K3's real checkpoint JSON deserializes end to end
test_transparency_fill_stage_defaults_without_explicit_key absent stage falls back to before_resize, as the reference does

Plus fill_transparent_bg_is_identity_without_alpha, fill_transparent_bg_survives_zero_square_size, transparent_bg_config_fills_missing_fields.

Before → after for a fully transparent 56×56 input with K3's config, mean = std = 0.5:

normalized value what the encoder sees
before -1.0 solid black
after +1.0 / +0.569 chessboard (255 / 200)

Deliberately out of scope

Kept separate so this PR stays reviewable and bisectable:

  • in_patch_limit: K3's checkpoint raises it to 65536; SMG uses K2.5's 16384. Large images get ~4× fewer visual tokens than they should (4000×3000 → 4200 instead of 15444). Changes token accounting on both models, so it needs its own PR.
  • Resize kernel divergence above 3.2 MP, K2.5's cross-path alpha inconsistency (shared with Qwen/Phi/Inkling), missing Kimi golden fixtures.
Checklist
  • cargo +nightly fmt passes
  • cargo clippy --all-targets --all-features -- -D warnings passes
  • (Optional) Documentation updated
  • (Optional) Please join us on Slack #sig-smg to discuss, review, and merge PRs

Summary by CodeRabbit

  • New Features
    • Added support for Kimi K3 vision processing.
    • Added configurable transparent-background handling for images with alpha channels, including chessboard backgrounds and selectable processing stages.
    • Added improved alpha-aware image resizing.
    • Added shared MoonViT-based image preprocessing for Kimi vision models.
  • Bug Fixes
    • Corrected model registration so Kimi K3 uses its dedicated processor.
    • Preserved expected processing behavior for opaque images and Kimi K2.5.

key4ng added 2 commits July 28, 2026 14:59
Kimi-K2.5 and Kimi-K3 both encode images with MoonViT and differ only in
their preprocessing parameters, but SMG registered `kimi-k3` against
`KimiK25Processor` directly, leaving nowhere to express a K3-specific
difference. The reference implementation instead factors the NaViT
resize/patchify helpers into a shared `media_utils` module and keeps a
thin per-model processor class on top.

Mirror that split, using the same shape `qwen_vl_base` already provides
for the Qwen VL family:

- Move the MoonViT pipeline into `processors::moonvit` as
  `MoonVitProcessorBase`, parameterized by `MoonVitConfig`.
- Reduce `KimiK25Processor` to a thin wrapper that `Deref`s to the base.
- Add `KimiK3Processor` as a second wrapper with `model_name: "kimi-k3"`.
- Point the `kimi-k3` / `kimi_k3` registrations at it.

This commit is a no-op: `KimiK3Processor` reuses K2.5's parameters, so
both processors still produce byte-identical output. A test asserts that
directly, which anchors the behavior change that follows.

Signed-off-by: key4ng <rukeyang@gmail.com>
K3's reference processor composites alpha-carrying images over a
background before the NaViT resize and patchify; SMG dropped the alpha
channel instead. That is not the same operation. A fully transparent PNG
pixel usually stores RGB (0,0,0) under its alpha, so dropping alpha
normalizes it to -1.0 — solid black — where the model was trained to see
the chessboard the checkpoint describes. Logos, icons, screenshots with
rounded corners, and anything else served as RGBA reached the encoder
with its background inverted.

Add the compositing step to the MoonViT base and let K3 drive it:

- `transforms`: `TransparentBgConfig` / `TransparentBgPattern` /
  `TransparentBgFillStage` deserialize straight from a checkpoint, and
  `fill_transparent_bg` blends `a*img + (1-a)*bg` with the reference's
  truncating uint8 cast.
- `transforms::resize_straight_alpha`: our SIMD resizer premultiplies by
  alpha by default, which drags RGB toward zero wherever alpha is
  partial. Pillow convolves channels independently, so compositing after
  a resize needs the non-premultiplied variant.
- `PreProcessorConfig::transparent_bg()` lifts the two keys out of
  `media_proc_cfg` and returns them together, since the fill stage is
  only meaningful alongside a config.
- `KimiK3Processor` resolves them per request rather than hardcoding
  them, matching the reference, which falls back to dropping alpha when
  `transparent_bg_config` is absent. K2.5 declares `transparent_bg:
  None` because its reference class has no transparency handling at all.

The fill stage is load-bearing, not cosmetic: a chessboard is generated
at the resolution of the image it lands on, so `before_resize` and
`after_resize` produce different square sizes relative to the content.
K3's checkpoint ships `after_resize`.

Opaque images are untouched — they skip the compositing branch entirely,
and a test asserts a declared background does not perturb them.

Out of scope, tracked separately: K3's checkpoint also raises
`in_patch_limit` to 65536, which changes token accounting.

Signed-off-by: key4ng <rukeyang@gmail.com>
@gemini-code-assist

Copy link
Copy Markdown
Contributor

Caution

The consumer version of Gemini Code Assist on GitHub has been sunset. All code review activity has officially ceased.

@github-actions github-actions Bot added the multimodal Multimodal crate changes label Jul 28, 2026
@coderabbitai

coderabbitai Bot commented Jul 28, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds shared MoonViT image preprocessing, transparent-background compositing and resizing, Kimi K3 processor registration, and Kimi K2.5 delegation to the shared pipeline. Configuration parsing now exposes transparency settings and fill-stage defaults.

Changes

Kimi MoonViT preprocessing

Layer / File(s) Summary
Transparent background configuration and transforms
crates/multimodal/src/vision/transforms.rs, crates/multimodal/src/vision/preprocessor_config.rs
Adds transparency configuration types, chessboard generation, alpha compositing, straight-alpha resizing, nested configuration extraction, fill-stage defaults, and tests.
Shared MoonViT preprocessing pipeline
crates/multimodal/src/vision/processors/moonvit.rs
Adds shared resize, padding, normalization, alpha handling, patch extraction, token counting, and encoder-input construction.
Kimi processor integration and registration
crates/multimodal/src/vision/processors/kimi_k25.rs, crates/multimodal/src/vision/processors/kimi_k3.rs, crates/multimodal/src/vision/processors/mod.rs, crates/multimodal/src/vision/processor.rs
Refactors K2.5 onto MoonViT, adds K3 with request-resolved transparency behavior, exports the new processors, updates model registration, and adds processor tests.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant ModelRegistry
  participant KimiK3Processor
  participant PreProcessorConfig
  participant MoonVitProcessorBase
  participant EncoderInputs
  ModelRegistry->>KimiK3Processor: select processor for kimi-k3
  KimiK3Processor->>PreProcessorConfig: read transparency settings
  KimiK3Processor->>MoonVitProcessorBase: preprocess with resolved config
  MoonVitProcessorBase->>EncoderInputs: return patches and grid metadata
Loading

Suggested reviewers: catherinesue, slin1237

Poem

I’m a bunny with pixels to spare,
Painting soft chessboards in the air.
K3 hops through MoonViT’s gate,
K2.5 keeps its alpha state.
Resize, patch, and onward we go—
Carrots for every tensor row!

🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Docstring Coverage ✅ Passed Docstring coverage is 100.00% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly matches the main change: adding transparent-image compositing support for Kimi-K3 in multimodal preprocessing.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/kimi-k3-transparent-bg

Comment @coderabbitai help to get the list of available commands.

@claude claude Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Clean, well-tested PR. Reviewed the full diff across all 7 changed files.

Refactoring (commit 1): The extraction of MoonVitProcessorBase into moonvit.rs is a faithful code move — the K2.5 wrapper becomes a pure Deref delegate and the opaque_images_match_k25_byte_for_byte test locks this as a no-op.

Bug fix (commit 2): The compositing logic in fill_transparent_bg correctly implements α·fg + (1−α)·bg with the truncating as u8 cast matching NumPy's astype(np.uint8). The resize_straight_alpha path correctly disables premultiplication via use_alpha(false) (fast_image_resize 6.x defaults to true, so the existing resize() path is unchanged). The BeforeResize/AfterResize stage dispatch handles the chessboard-resolution distinction correctly.

Design boundary: K2.5 hardcodes transparent_bg: None at construction and delegates directly to the base — transparency config in the PreProcessorConfig is intentionally invisible to it. K3's resolved() reads it per-request via Cow, which is the right place since the registry hands out default-constructed processors. This mirrors the Qwen VL family's pattern.

Edge cases verified: opaque images skip compositing; zero square size clamps to 1; missing transparent_bg_fill_stage defaults to BeforeResize; fill stage distinction is asserted by fill_stage_paints_the_board_at_its_own_resolution.

12 new tests, 0 issues found (0 🔴, 0 🟡, 0 🟣).

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@crates/multimodal/src/vision/preprocessor_config.rs`:
- Around line 442-454: Update transparent_bg to distinguish an absent
transparent_bg_config from a present but unparseable value, and log the
deserialization error when parsing fails. Preserve returning None only when the
key is absent, while valid configs should continue constructing TransparentBg
with the existing stage behavior.

In `@crates/multimodal/src/vision/processors/kimi_k3.rs`:
- Around line 17-21: Update KimiK3Processor’s request-time resolution flow,
including resolved() and the registry-created KimiK3Processor::new() path, to
resolve in_patch_limit and patch_limit_on_one_side from the current preprocessor
configuration just as transparent_bg is resolved. Ensure
from_preprocessor_config participates in the request path so K3 uses the
checkpoint’s 65536 patch budget instead of the default K2.5 limit, while
preserving existing transparency handling.

In `@crates/multimodal/src/vision/transforms.rs`:
- Around line 129-149: Update fill_transparent_bg to return an empty RgbImage
before calling chunks_exact when the decoded image width or height is zero,
preserving the existing non-alpha conversion and compositing behavior for
non-empty images.
- Around line 366-395: Update the doc comment for resize_straight_alpha to refer
to ResizeOptions::use_alpha instead of ResizeOptions::mul_div_alpha. Leave the
implementation and all other documentation unchanged.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: fb98a171-34a1-4948-a85a-3fe7946fab15

📥 Commits

Reviewing files that changed from the base of the PR and between c5de762 and d926d91.

📒 Files selected for processing (7)
  • crates/multimodal/src/vision/preprocessor_config.rs
  • crates/multimodal/src/vision/processor.rs
  • crates/multimodal/src/vision/processors/kimi_k25.rs
  • crates/multimodal/src/vision/processors/kimi_k3.rs
  • crates/multimodal/src/vision/processors/mod.rs
  • crates/multimodal/src/vision/processors/moonvit.rs
  • crates/multimodal/src/vision/transforms.rs

Comment on lines +442 to +454
/// The checkpoint's alpha-flattening behavior, if it declares any.
///
/// `None` means the checkpoint asks for no compositing, matching the
/// reference's behavior when `transparent_bg_config` is absent. The fill
/// stage is only meaningful alongside a config, so it is read here rather
/// than exposed on its own.
pub fn transparent_bg(&self) -> Option<transforms::TransparentBg> {
let config = self.get_extra::<transforms::TransparentBgConfig>("transparent_bg_config")?;
let stage = self
.get_extra::<transforms::TransparentBgFillStage>("transparent_bg_fill_stage")
.unwrap_or_default();
Some(transforms::TransparentBg { config, stage })
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

A malformed transparent_bg_config silently degrades to alpha-drop.

get_extra discards deserialization errors, so a checkpoint that ships "pattern": "chess_board" (or any type mismatch) yields None here, and K3 then renders transparent regions as solid black with no signal anywhere. Log when the key is present but unparseable so the misconfiguration is diagnosable.

♻️ Surface the parse failure
     pub fn transparent_bg(&self) -> Option<transforms::TransparentBg> {
-        let config = self.get_extra::<transforms::TransparentBgConfig>("transparent_bg_config")?;
+        let raw = self.extra.get("transparent_bg_config")?;
+        let config = match serde_json::from_value::<transforms::TransparentBgConfig>(raw.clone()) {
+            Ok(config) => config,
+            Err(e) => {
+                tracing::warn!(error = %e, "unparseable transparent_bg_config; alpha will be dropped");
+                return None;
+            }
+        };
         let stage = self
             .get_extra::<transforms::TransparentBgFillStage>("transparent_bg_fill_stage")
             .unwrap_or_default();
         Some(transforms::TransparentBg { config, stage })
     }
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
/// The checkpoint's alpha-flattening behavior, if it declares any.
///
/// `None` means the checkpoint asks for no compositing, matching the
/// reference's behavior when `transparent_bg_config` is absent. The fill
/// stage is only meaningful alongside a config, so it is read here rather
/// than exposed on its own.
pub fn transparent_bg(&self) -> Option<transforms::TransparentBg> {
let config = self.get_extra::<transforms::TransparentBgConfig>("transparent_bg_config")?;
let stage = self
.get_extra::<transforms::TransparentBgFillStage>("transparent_bg_fill_stage")
.unwrap_or_default();
Some(transforms::TransparentBg { config, stage })
}
/// The checkpoint's alpha-flattening behavior, if it declares any.
///
/// `None` means the checkpoint asks for no compositing, matching the
/// reference's behavior when `transparent_bg_config` is absent. The fill
/// stage is only meaningful alongside a config, so it is read here rather
/// than exposed on its own.
pub fn transparent_bg(&self) -> Option<transforms::TransparentBg> {
let raw = self.extra.get("transparent_bg_config")?;
let config = match serde_json::from_value::<transforms::TransparentBgConfig>(raw.clone()) {
Ok(config) => config,
Err(e) => {
tracing::warn!(error = %e, "unparseable transparent_bg_config; alpha will be dropped");
return None;
}
};
let stage = self
.get_extra::<transforms::TransparentBgFillStage>("transparent_bg_fill_stage")
.unwrap_or_default();
Some(transforms::TransparentBg { config, stage })
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/vision/preprocessor_config.rs` around lines 442 - 454,
Update transparent_bg to distinguish an absent transparent_bg_config from a
present but unparseable value, and log the deserialization error when parsing
fails. Preserve returning None only when the key is absent, while valid configs
should continue constructing TransparentBg with the existing stage behavior.

Comment on lines +17 to +21
//! Known remaining difference, deliberately left for a follow-up change: K3's
//! checkpoint raises `in_patch_limit` to 65536, so large images should keep
//! roughly 4x the visual tokens they get from K2.5's budget. Reading the
//! budget from the checkpoint touches token accounting on both models and is
//! tracked separately from this module's transparency handling.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

📐 Maintainability & Code Quality | 🔵 Trivial

Documented gap: only transparency is re-resolved per request, not the patch budget.

resolved() lifts transparent_bg from the request config, but in_patch_limit/patch_limit_on_one_side stay at the default-constructed values because the registry hands out KimiK3Processor::new(). So from_preprocessor_config's limit reading never runs on the request path and K3 keeps K2.5's 16384 budget, as the module doc notes.

Want me to open a follow-up issue to track lifting the patch budget through the same call-time resolution?

Also applies to: 96-115

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/vision/processors/kimi_k3.rs` around lines 17 - 21,
Update KimiK3Processor’s request-time resolution flow, including resolved() and
the registry-created KimiK3Processor::new() path, to resolve in_patch_limit and
patch_limit_on_one_side from the current preprocessor configuration just as
transparent_bg is resolved. Ensure from_preprocessor_config participates in the
request path so K3 uses the checkpoint’s 65536 patch budget instead of the
default K2.5 limit, while preserving existing transparency handling.

Comment on lines +129 to +149
pub fn fill_transparent_bg(image: &DynamicImage, config: TransparentBgConfig) -> RgbImage {
if !image.color().has_alpha() {
return image.to_rgb8();
}

let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let mut out = Vec::with_capacity(width as usize * height as usize * 3);

for (y, row) in rgba.as_raw().chunks_exact(width as usize * 4).enumerate() {
for (x, px) in row.chunks_exact(4).enumerate() {
let bg = f32::from(config.background_at(x as u32, y as u32));
let alpha = f32::from(px[3]) / 255.0;
let inv = 1.0 - alpha;
// numpy's `.astype(np.uint8)` truncates rather than rounds, and so
// does an `as` cast; keep them consistent.
out.push((alpha * f32::from(px[0]) + inv * bg) as u8);
out.push((alpha * f32::from(px[1]) + inv * bg) as u8);
out.push((alpha * f32::from(px[2]) + inv * bg) as u8);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

chunks_exact panics on a zero-width image.

chunks_exact(width as usize * 4) panics ("chunk size must be non-zero") when width == 0. The caller in moonvit.rs only checks has_alpha(), so a degenerate 0-width/0-height decode reaches here and panics on the request path instead of producing an empty/aligned canvas.

🛡️ Proposed guard
     let rgba = image.to_rgba8();
     let (width, height) = rgba.dimensions();
+    if width == 0 || height == 0 {
+        return RgbImage::new(width, height);
+    }
     let mut out = Vec::with_capacity(width as usize * height as usize * 3);
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
pub fn fill_transparent_bg(image: &DynamicImage, config: TransparentBgConfig) -> RgbImage {
if !image.color().has_alpha() {
return image.to_rgb8();
}
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
let mut out = Vec::with_capacity(width as usize * height as usize * 3);
for (y, row) in rgba.as_raw().chunks_exact(width as usize * 4).enumerate() {
for (x, px) in row.chunks_exact(4).enumerate() {
let bg = f32::from(config.background_at(x as u32, y as u32));
let alpha = f32::from(px[3]) / 255.0;
let inv = 1.0 - alpha;
// numpy's `.astype(np.uint8)` truncates rather than rounds, and so
// does an `as` cast; keep them consistent.
out.push((alpha * f32::from(px[0]) + inv * bg) as u8);
out.push((alpha * f32::from(px[1]) + inv * bg) as u8);
out.push((alpha * f32::from(px[2]) + inv * bg) as u8);
}
}
pub fn fill_transparent_bg(image: &DynamicImage, config: TransparentBgConfig) -> RgbImage {
if !image.color().has_alpha() {
return image.to_rgb8();
}
let rgba = image.to_rgba8();
let (width, height) = rgba.dimensions();
if width == 0 || height == 0 {
return RgbImage::new(width, height);
}
let mut out = Vec::with_capacity(width as usize * height as usize * 3);
for (y, row) in rgba.as_raw().chunks_exact(width as usize * 4).enumerate() {
for (x, px) in row.chunks_exact(4).enumerate() {
let bg = f32::from(config.background_at(x as u32, y as u32));
let alpha = f32::from(px[3]) / 255.0;
let inv = 1.0 - alpha;
// numpy's `.astype(np.uint8)` truncates rather than rounds, and so
// does an `as` cast; keep them consistent.
out.push((alpha * f32::from(px[0]) + inv * bg) as u8);
out.push((alpha * f32::from(px[1]) + inv * bg) as u8);
out.push((alpha * f32::from(px[2]) + inv * bg) as u8);
}
}
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/vision/transforms.rs` around lines 129 - 149, Update
fill_transparent_bg to return an empty RgbImage before calling chunks_exact when
the decoded image width or height is zero, preserving the existing non-alpha
conversion and compositing behavior for non-empty images.

Comment on lines +366 to +395
/// Resize without premultiplying by alpha, matching `PIL.Image.resize`.
///
/// Pillow convolves every channel independently, alpha included. Our SIMD
/// resizer premultiplies by default (`ResizeOptions::mul_div_alpha`), which
/// changes RGB wherever alpha is partial and would diverge from any reference
/// pipeline that composites *after* the resize.
pub fn resize_straight_alpha(
image: &DynamicImage,
width: u32,
height: u32,
filter: FilterType,
) -> DynamicImage {
resize_inner(image, width, height, filter, false)
}

fn resize_inner(
image: &DynamicImage,
width: u32,
height: u32,
filter: FilterType,
premultiply_alpha: bool,
) -> DynamicImage {
let pixel_type = match image.pixel_type() {
Some(pt) => pt,
None => return image.resize_exact(width, height, filter),
};
let mut dst = FirImage::new(width, height, pixel_type);
let options = ResizeOptions::new().resize_alg(to_fir_algorithm(filter));
let options = ResizeOptions::new()
.resize_alg(to_fir_algorithm(filter))
.use_alpha(premultiply_alpha);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🔵 Trivial | 💤 Low value

🧩 Analysis chain

🌐 Web query:

fast_image_resize 6.0 ResizeOptions use_alpha default premultiply alpha

💡 Result:

In the fast_image_resize 6.0 library, the ResizeOptions struct controls the resizing process, including how alpha channels are handled [1][2]. The field controlling alpha channel consideration is mul_div_alpha [1]. When enabled, the resizer automatically multiplies the source image's color channels by its alpha channel before resizing and divides the destination image's color channels by the alpha channel after resizing [3]. Key details regarding mul_div_alpha: - Default Value: The default value for mul_div_alpha is true [1][2]. - Purpose: It enables or disables the consideration of the alpha channel during the resize process [1]. - Method: You can use the use_alpha(bool) method on ResizeOptions to set this value [1][2]. - Supported Types: By default, the Resizer performs this multiplication and division for images with U8x2, U8x4, U16x2, and U16x4 pixel types [3]. If you explicitly set mul_div_alpha to true (or rely on the default), the library ensures that color channels are premultiplied by alpha prior to convolution-based filtering to avoid dark fringes or incorrect color bleeding, and then unpremultiplied in the final result [2][3].

Citations:


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Inspect the relevant file around the cited lines.
sed -n '330,430p' crates/multimodal/src/vision/transforms.rs

echo '---'
# Find all references to fast_image_resize alpha options in the repo.
rg -n "use_alpha|mul_div_alpha|ResizeOptions" crates/multimodal/src -S

Repository: lightseekorg/smg

Length of output: 4431


🏁 Script executed:

#!/bin/bash
set -euo pipefail

# Read docs for the fast_image_resize API from the local cargo registry if available.
# This is read-only and only inspects installed documentation/source.
python3 - <<'PY'
import os, glob, textwrap, pathlib, json, sys
home = os.path.expanduser("~")
candidates = []
for base in [
    os.path.join(home, ".cargo", "registry", "src"),
    os.path.join(home, ".cargo", "registry", "cache"),
]:
    if os.path.exists(base):
        candidates.append(base)
print("\n".join(candidates))
PY

Repository: lightseekorg/smg

Length of output: 155


Fix the doc comment API name. use_alpha(true) is fine here; the comment should refer to ResizeOptions::use_alpha, not ResizeOptions::mul_div_alpha.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@crates/multimodal/src/vision/transforms.rs` around lines 366 - 395, Update
the doc comment for resize_straight_alpha to refer to ResizeOptions::use_alpha
instead of ResizeOptions::mul_div_alpha. Leave the implementation and all other
documentation unchanged.

@key4ng

key4ng commented Jul 28, 2026

Copy link
Copy Markdown
Member Author

Superseded by #1984, which implements the same transparency fix plus the in_patch_limit correction, and follows SMG's convention that each vision processor owns its model-specific defaults (see grpc/multimodal/config.rs: preprocessor_config.json is optional). Closing in favor of that PR.

@key4ng key4ng closed this Jul 28, 2026
@lightseek-bot
lightseek-bot deleted the fix/kimi-k3-transparent-bg branch July 29, 2026 00:37
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

multimodal Multimodal crate changes

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant