fix(parser): drop hard image edge/pixel limit, add opt-in raster byte ceiling (#20745)

This commit is contained in:
xugangqiang
2026-10-10 19:54:28 +08:00
committed by GitHub
parent 97b648fd32
commit fd77f9d7ca
6 changed files with 200 additions and 26 deletions
@@ -160,8 +160,14 @@ func decodeDispatchImage(data []byte, decodeRaster bool) (image.Image, error) {
if err != nil {
return nil, fmt.Errorf("parser: decode image: %w", err)
}
if config.Width <= 0 || config.Height <= 0 || config.Width > parser.MaxImageEdge || config.Height > parser.MaxImageEdge || int64(config.Width)*int64(config.Height) > parser.MaxImagePixels {
return nil, fmt.Errorf("parser: image dimensions %dx%d exceed limits", config.Width, config.Height)
if config.Width <= 0 || config.Height <= 0 {
return nil, fmt.Errorf("parser: image has invalid dimensions %dx%d", config.Width, config.Height)
}
// Length/width are unbounded (mirrors Python's picture.py); the decoded
// raster byte ceiling is opt-in via RAGFLOW_IMAGE_RASTER_MAX_BYTES (0 =
// unlimited).
if err := parser.CheckImageRasterLimit(config.Width, config.Height); err != nil {
return nil, fmt.Errorf("parser: %w", err)
}
// VLM consumes the original bytes; only local OCR needs a decoded raster.
if !decodeRaster {
@@ -159,7 +159,7 @@ func (c *visionPDFCropper) Crop(ctx context.Context, item map[string]any) (*visi
if len(single) == 0 {
return nil, nil
}
raster := util.CropSectionPositionsRasterLimited(positions, single, deepdoctype.DlaScale, maxVisionImagePixels)
raster := util.CropSectionPositionsRasterLimited(positions, single, deepdoctype.DlaScale, maxVLMCropPixels)
if raster == nil {
return nil, nil
}
@@ -182,12 +182,12 @@ func pdfPagesRasterWithinVisionLimits(engine deepdoctype.PDFEngine, pageNums map
if math.IsNaN(widthPixels) || math.IsInf(widthPixels, 0) ||
math.IsNaN(heightPixels) || math.IsInf(heightPixels, 0) ||
widthPixels <= 0 || heightPixels <= 0 ||
widthPixels > maxVisionImageEdge || heightPixels > maxVisionImageEdge ||
widthPixels*heightPixels > float64(maxVisionImagePixels) {
widthPixels > maxVLMCropEdge || heightPixels > maxVLMCropEdge ||
widthPixels*heightPixels > float64(maxVLMCropPixels) {
return false
}
pagePixels := int64(widthPixels * heightPixels)
if pagePixels > maxVisionImagePixels-totalPixels {
if pagePixels > maxVLMCropPixels-totalPixels {
return false
}
totalPixels += pagePixels
+21 -7
View File
@@ -35,10 +35,17 @@ import (
)
const (
maxVisionImageBytes = parserlib.MaxImagePayloadBytes
maxVisionImagePixels = parserlib.MaxImagePixels
maxVisionImageEdge = parserlib.MaxImageEdge
maxVLMEncodedBytes = (maxVisionImageBytes+2)/3*4 + 256
maxVisionImageBytes = parserlib.MaxImagePayloadBytes
maxVLMEncodedBytes = (maxVisionImageBytes+2)/3*4 + 256
// maxVLMCropPixels and maxVLMCropEdge bound the raster of PDF page crops
// rendered for the vision model (see vision_enhancement_cgo.go). They are a
// pixel budget for the VLM crop path and are intentionally separate from
// the file/inline image parser, which now uses the opt-in raster-byte limit
// (RAGFLOW_IMAGE_RASTER_MAX_BYTES, default 0 = unlimited). These keep their
// historical values because a 0-byte ceiling would map to a 0-pixel crop
// budget and reject every VLM crop.
maxVLMCropPixels = 40_000_000
maxVLMCropEdge = 12_000
)
func materializeInlineVisionImage(raw string) (*visionImage, error) {
@@ -96,8 +103,11 @@ func inspectVisionImagePayload(payload string) (string, error) {
if err != nil {
continue
}
if config.Width <= 0 || config.Height <= 0 || config.Width > maxVisionImageEdge || config.Height > maxVisionImageEdge || int64(config.Width)*int64(config.Height) > maxVisionImagePixels {
return "", fmt.Errorf("vision image: image dimensions %dx%d exceed limits", config.Width, config.Height)
if config.Width <= 0 || config.Height <= 0 {
return "", fmt.Errorf("vision image: image has invalid dimensions %dx%d", config.Width, config.Height)
}
if err := parserlib.CheckImageRasterLimit(config.Width, config.Height); err != nil {
return "", fmt.Errorf("vision image: %w", err)
}
return format, nil
}
@@ -165,7 +175,11 @@ func imageWithinVisionLimits(img image.Image) bool {
}
bounds := img.Bounds()
width, height := bounds.Dx(), bounds.Dy()
return width > 0 && height > 0 && width <= maxVisionImageEdge && height <= maxVisionImageEdge && int64(width)*int64(height) <= maxVisionImagePixels
if width <= 0 || height <= 0 {
return false
}
// Edge is unbounded (mirrors Python); only the opt-in raster-byte ceiling applies.
return parserlib.CheckImageRasterLimit(width, height) == nil
}
func imageMIMEForFormat(format string) string {
@@ -25,11 +25,14 @@ import (
"testing"
)
func TestMaterializeInlineVisionImageKeepsVLMForImageDimensionLimit(t *testing.T) {
func TestMaterializeInlineVisionImageNoEdgeLimitKeepsVLM(t *testing.T) {
var encoded bytes.Buffer
large := image.NewRGBA(image.Rect(0, 0, maxVisionImageEdge+1, 1))
if err := png.Encode(&encoded, large); err != nil {
t.Fatalf("encode oversized image: %v", err)
// Width exceeds the former 12000-edge guard. With the edge limit removed
// (aligned to Python's picture.py, which imposes none) the inline image is
// still materialized and its VLM payload preserved for the vision model.
wide := image.NewRGBA(image.Rect(0, 0, 12001, 1))
if err := png.Encode(&encoded, wide); err != nil {
t.Fatalf("encode wide image: %v", err)
}
dataURI := "data:image/png;base64," + base64.StdEncoding.EncodeToString(encoded.Bytes())
@@ -37,11 +40,8 @@ func TestMaterializeInlineVisionImageKeepsVLMForImageDimensionLimit(t *testing.T
if err != nil {
t.Fatalf("materializeInlineVisionImage: %v", err)
}
if materialized == nil || materialized.Raster != nil {
t.Fatalf("raster = %#v, want OCR to skip the oversized image", materialized)
}
if materialized.VLMData != dataURI || !isUsableVisionImage(materialized.VLMData) {
t.Fatal("oversized OCR input should preserve its valid VLM payload")
if materialized == nil || materialized.VLMData != dataURI || !isUsableVisionImage(materialized.VLMData) {
t.Fatalf("wide inline image must preserve its valid VLM payload (no edge limit): %#v", materialized)
}
}
+67 -4
View File
@@ -18,6 +18,10 @@ package parser
import (
"context"
"fmt"
"os"
"strconv"
"strings"
"sync"
deepdocpdf "ragflow/internal/deepdoc/parser/pdf"
@@ -26,12 +30,71 @@ import (
const (
// MaxImagePayloadBytes bounds compressed image data accepted by media paths.
MaxImagePayloadBytes = 32 << 20
// MaxImagePixels bounds decoded image dimensions before allocating a raster.
MaxImagePixels = 40_000_000
// MaxImageEdge bounds either decoded image dimension before raster allocation.
MaxImageEdge = 12_000
)
// EnvImageRasterMaxBytes names the environment variable that sets the
// decoded-raster byte ceiling for image parsing (media dispatch and inline
// vision images). It mirrors Python's picture.py, which imposes no image size
// guard at all: length/width are unbounded and the raster-byte ceiling is opt
// in. A value of 0 (the default when unset, empty, or invalid/negative) means
// unlimited. When set to N > 0 (bytes), a decoded raster larger than N bytes
// (modelled as width * height * 4 for an 8-bit RGBA raster) is rejected.
//
// Operator guidance: with the default (unlimited) ceiling, a highly
// compressible image can still exhaust the ingestion worker's heap during
// decode. For untrusted input sources, set RAGFLOW_IMAGE_RASTER_MAX_BYTES to a
// budget the deployment can absorb (bounded by DeepDocConcurrency). The 4
// bytes/pixel model is an 8-bit RGBA estimate; 16-bit formats (e.g. NRGBA64)
// allocate roughly twice that, so size the ceiling with headroom for such
// input.
const EnvImageRasterMaxBytes = "RAGFLOW_IMAGE_RASTER_MAX_BYTES"
var (
imageRasterMaxBytesOnce sync.Once
imageRasterMaxBytes int64
)
// ImageRasterMaxBytes returns the decoded-raster byte ceiling. It is resolved
// once from EnvImageRasterMaxBytes; negative or unparseable values fall back to
// 0 (unlimited) so a misconfiguration never silently blocks every image.
func ImageRasterMaxBytes() int64 {
imageRasterMaxBytesOnce.Do(func() {
imageRasterMaxBytes = resolveImageRasterMaxBytes()
})
return imageRasterMaxBytes
}
func resolveImageRasterMaxBytes() int64 {
v := strings.TrimSpace(os.Getenv(EnvImageRasterMaxBytes))
if v == "" {
return 0
}
n, err := strconv.ParseInt(v, 10, 64)
if err != nil || n < 0 {
return 0
}
return n
}
// CheckImageRasterLimit returns an error if a w-by-h image's decoded RGBA
// raster would exceed the opt-in byte ceiling (ImageRasterMaxBytes). Edge
// (length/width) limits are intentionally absent to match Python's picture.py.
// A zero ceiling means unlimited and always passes.
//
// The raster is modelled as 4 bytes/pixel (8-bit RGBA); see EnvImageRasterMaxBytes
// for the 16-bit headroom note. The product is computed in uint64 so extreme
// dimensions cannot silently wrap to 0 under int64 signed overflow and bypass
// a positive ceiling.
func CheckImageRasterLimit(w, h int) error {
if max := ImageRasterMaxBytes(); max > 0 {
const bytesPerPixel = 4
if uint64(w)*uint64(h)*bytesPerPixel > uint64(max) {
return fmt.Errorf("image raster %dx%d exceeds %d-byte limit", w, h, max)
}
}
return nil
}
// AcquireImageMedia limits concurrent image materialization across Parser
// payload encoding and ingestion VLM crop paths. The native analyzer keeps
// its own inference limit.
@@ -0,0 +1,91 @@
//
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
//
package parser
import (
"sync"
"testing"
)
// resetImageRasterMaxBytesForTest re-reads EnvImageRasterMaxBytes between
// subtests by resetting the once guard. It lives in the test file so it is not
// compiled into production; the parser package's unexported vars are reachable
// because this is a same-package (white-box) test.
func resetImageRasterMaxBytesForTest() {
imageRasterMaxBytesOnce = sync.Once{}
imageRasterMaxBytes = 0
}
// TestImageRasterMaxBytesResolution covers env parsing: unset/zero/invalid/negative
// all mean unlimited (0), while a positive integer is honoured.
func TestImageRasterMaxBytesResolution(t *testing.T) {
cases := []struct {
name string
env string
want int64
}{
{"unset means unlimited", "", 0},
{"explicit zero means unlimited", "0", 0},
{"positive value honoured", "123456789", 123456789},
{"invalid falls back to unlimited", "not-a-number", 0},
{"negative falls back to unlimited", "-5", 0},
{"whitespace-trimmed zero", " 0 ", 0},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv(EnvImageRasterMaxBytes, c.env)
resetImageRasterMaxBytesForTest()
t.Cleanup(resetImageRasterMaxBytesForTest)
if got := ImageRasterMaxBytes(); got != c.want {
t.Errorf("ImageRasterMaxBytes() with env %q = %d, want %d", c.env, got, c.want)
}
})
}
}
// TestCheckImageRasterLimit covers the opt-in raster-byte ceiling against the
// real 2692x16384 image that previously tripped the hard dimension guard.
// raster bytes = 2692 * 16384 * 4 = 176,422,912.
func TestCheckImageRasterLimit(t *testing.T) {
cases := []struct {
name string
w, h int
env string
wantErr bool
}{
{"default unlimited passes huge image", 2692, 16384, "", false},
{"explicit zero passes huge image", 2692, 16384, "0", false},
{"negative treated unlimited passes", 2692, 16384, "-1", false},
{"invalid treated unlimited passes", 2692, 16384, "garbage", false},
{"ceiling below raster blocks", 2692, 16384, "100000000", true},
{"ceiling above raster passes", 2692, 16384, "200000000", false},
{"ceiling exactly equal passes (not strictly over)", 2692, 16384, "176422912", false},
{"ceiling one byte below blocks", 2692, 16384, "176422911", true},
{"small image under generous ceiling passes", 100, 100, "100000", false},
}
for _, c := range cases {
t.Run(c.name, func(t *testing.T) {
t.Setenv(EnvImageRasterMaxBytes, c.env)
resetImageRasterMaxBytesForTest()
t.Cleanup(resetImageRasterMaxBytesForTest)
err := CheckImageRasterLimit(c.w, c.h)
if (err != nil) != c.wantErr {
t.Errorf("CheckImageRasterLimit(%d,%d) env=%q: err=%v, wantErr=%v", c.w, c.h, c.env, err, c.wantErr)
}
})
}
}