feat(analysis): slice tall pages to the pixel budget for reliable OCR
Very tall webtoon pages were squashed by the fixed longest-edge downscale, so OCR was garbage. The vision client now slices to the model's pixel budget at native resolution and assembles one result: - plan_slices: native-resolution bands sized to max_pixels (portrait OR landscape), only when height/width > tall_aspect_threshold; min_slice_ height guards pathologically wide pages (reduce width); max_slices caps the count (coarser fallback). Normal pages still take one combined call. - Two-pass, OCR-first: Pass A OCRs each band (near-native), merge_ocr stitches them with seam-scoped fuzzy dedup (keep the longer transcript, preserve order/kind, don't collapse non-adjacent repeats); Pass B feeds the whole downscaled image + the merged OCR text to get tags/scene/safety grounded in the real dialogue. Only OCR is merged. - Config: replace max_image_dim with max_pixels / min_slice_height / slice_overlap / tall_aspect_threshold / max_slices (+ env_f64); bump job_timeout default 180->600 (N sequential slice calls per page); raise MAX_OCR_PIECES 60->200. New prompts/schemas: OCR_PROMPT/ocr_json_schema, GROUNDING_PROMPT/grounding_json_schema. Tests: plan_slices (single/portrait/landscape/pathological-wide/cap + coverage/overlap), merge_ocr (seam dedup, keep-longer, non-adjacent repeats, order), render_whole/render_slice, the new request builders, and updated config defaults/env. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -118,8 +118,23 @@ pub struct AnalysisConfig {
|
||||
/// length`) and the parse fails. The page image + prompt are only a few
|
||||
/// hundred tokens, so a large output budget still fits an 8k window.
|
||||
pub max_tokens: u32,
|
||||
/// Longest image edge (px) before downscaling (`ANALYSIS_MAX_IMAGE_DIM`).
|
||||
pub max_image_dim: u32,
|
||||
/// Per-slice / per-image pixel budget (`ANALYSIS_MAX_PIXELS`). The model
|
||||
/// resizes any input to roughly this many pixels anyway, so we slice/fit
|
||||
/// to it at native resolution rather than pre-squashing by a fixed edge.
|
||||
pub max_pixels: u32,
|
||||
/// Minimum slice height (px), the aspect guard for very wide pages
|
||||
/// (`ANALYSIS_MIN_SLICE_HEIGHT`). Implies `max_slice_width =
|
||||
/// max_pixels / min_slice_height`.
|
||||
pub min_slice_height: u32,
|
||||
/// Vertical overlap between adjacent slices as a fraction of slice height
|
||||
/// (`ANALYSIS_SLICE_OVERLAP`), so text straddling a cut survives.
|
||||
pub slice_overlap: f64,
|
||||
/// Slice only when `height/width` exceeds this (`ANALYSIS_TALL_ASPECT`);
|
||||
/// normal-aspect pages take a single combined call.
|
||||
pub tall_aspect_threshold: f64,
|
||||
/// Hard cap on slices per page (`ANALYSIS_MAX_SLICES`); beyond it slices
|
||||
/// grow coarser (and get downscaled to budget) rather than multiplying.
|
||||
pub max_slices: usize,
|
||||
/// Hard cap on a page image's stored size; larger pages are skipped
|
||||
/// (`ANALYSIS_MAX_IMAGE_BYTES`).
|
||||
pub max_image_bytes: usize,
|
||||
@@ -137,9 +152,16 @@ impl Default for AnalysisConfig {
|
||||
model: String::new(),
|
||||
api_key: None,
|
||||
request_timeout: Duration::from_secs(120),
|
||||
job_timeout: Duration::from_secs(180),
|
||||
// A sliced long page is N sequential calls under one job, so the
|
||||
// whole-job budget must be generous (request_timeout stays
|
||||
// per-call).
|
||||
job_timeout: Duration::from_secs(600),
|
||||
max_tokens: 4096,
|
||||
max_image_dim: 1024,
|
||||
max_pixels: 1_000_000,
|
||||
min_slice_height: 640,
|
||||
slice_overlap: 0.12,
|
||||
tall_aspect_threshold: 1.6,
|
||||
max_slices: 16,
|
||||
max_image_bytes: 8 * 1024 * 1024,
|
||||
response_format: ResponseFormat::JsonSchema,
|
||||
}
|
||||
@@ -166,7 +188,13 @@ impl AnalysisConfig {
|
||||
d.job_timeout.as_secs(),
|
||||
)),
|
||||
max_tokens: env_u64("ANALYSIS_MAX_TOKENS", d.max_tokens as u64) as u32,
|
||||
max_image_dim: env_u64("ANALYSIS_MAX_IMAGE_DIM", d.max_image_dim as u64) as u32,
|
||||
max_pixels: env_u64("ANALYSIS_MAX_PIXELS", d.max_pixels as u64) as u32,
|
||||
min_slice_height: env_u64("ANALYSIS_MIN_SLICE_HEIGHT", d.min_slice_height as u64)
|
||||
.max(1) as u32,
|
||||
slice_overlap: env_f64("ANALYSIS_SLICE_OVERLAP", d.slice_overlap).clamp(0.0, 0.9),
|
||||
tall_aspect_threshold: env_f64("ANALYSIS_TALL_ASPECT", d.tall_aspect_threshold)
|
||||
.max(1.0),
|
||||
max_slices: env_usize("ANALYSIS_MAX_SLICES", d.max_slices).max(1),
|
||||
max_image_bytes: env_usize("ANALYSIS_MAX_IMAGE_BYTES", d.max_image_bytes),
|
||||
response_format: std::env::var("ANALYSIS_RESPONSE_FORMAT")
|
||||
.map(|s| ResponseFormat::from_str(&s))
|
||||
@@ -506,6 +534,13 @@ fn env_i64(name: &str, default: i64) -> i64 {
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_f64(name: &str, default: f64) -> f64 {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
.and_then(|s| s.parse().ok())
|
||||
.unwrap_or(default)
|
||||
}
|
||||
|
||||
fn env_usize(name: &str, default: usize) -> usize {
|
||||
std::env::var(name)
|
||||
.ok()
|
||||
@@ -578,7 +613,11 @@ mod tests {
|
||||
"ANALYSIS_MODEL",
|
||||
"ANALYSIS_API_KEY",
|
||||
"ANALYSIS_MAX_TOKENS",
|
||||
"ANALYSIS_MAX_IMAGE_DIM",
|
||||
"ANALYSIS_MAX_PIXELS",
|
||||
"ANALYSIS_MIN_SLICE_HEIGHT",
|
||||
"ANALYSIS_SLICE_OVERLAP",
|
||||
"ANALYSIS_TALL_ASPECT",
|
||||
"ANALYSIS_MAX_SLICES",
|
||||
"ANALYSIS_RESPONSE_FORMAT",
|
||||
] {
|
||||
std::env::remove_var(k);
|
||||
@@ -586,7 +625,10 @@ mod tests {
|
||||
let cfg = AnalysisConfig::from_env();
|
||||
assert!(!cfg.enabled);
|
||||
assert_eq!(cfg.workers, 1);
|
||||
assert_eq!(cfg.max_image_dim, 1024);
|
||||
assert_eq!(cfg.max_pixels, 1_000_000);
|
||||
assert_eq!(cfg.min_slice_height, 640);
|
||||
assert_eq!(cfg.max_slices, 16);
|
||||
assert_eq!(cfg.tall_aspect_threshold, 1.6);
|
||||
assert!(cfg.api_key.is_none());
|
||||
assert_eq!(cfg.response_format, ResponseFormat::JsonSchema);
|
||||
}
|
||||
@@ -614,14 +656,18 @@ mod tests {
|
||||
std::env::set_var("ANALYSIS_WORKERS", "4");
|
||||
std::env::set_var("ANALYSIS_VISION_URL", "http://vis/v1/chat");
|
||||
std::env::set_var("ANALYSIS_MODEL", "qwen2-vl");
|
||||
std::env::set_var("ANALYSIS_MAX_IMAGE_DIM", "768");
|
||||
std::env::set_var("ANALYSIS_MAX_PIXELS", "768000");
|
||||
std::env::set_var("ANALYSIS_MAX_SLICES", "8");
|
||||
std::env::set_var("ANALYSIS_SLICE_OVERLAP", "0.2");
|
||||
let cfg = AnalysisConfig::from_env();
|
||||
for k in [
|
||||
"ANALYSIS_ENABLED",
|
||||
"ANALYSIS_WORKERS",
|
||||
"ANALYSIS_VISION_URL",
|
||||
"ANALYSIS_MODEL",
|
||||
"ANALYSIS_MAX_IMAGE_DIM",
|
||||
"ANALYSIS_MAX_PIXELS",
|
||||
"ANALYSIS_MAX_SLICES",
|
||||
"ANALYSIS_SLICE_OVERLAP",
|
||||
] {
|
||||
std::env::remove_var(k);
|
||||
}
|
||||
@@ -629,7 +675,9 @@ mod tests {
|
||||
assert_eq!(cfg.workers, 4);
|
||||
assert_eq!(cfg.endpoint, "http://vis/v1/chat");
|
||||
assert_eq!(cfg.model, "qwen2-vl");
|
||||
assert_eq!(cfg.max_image_dim, 768);
|
||||
assert_eq!(cfg.max_pixels, 768_000);
|
||||
assert_eq!(cfg.max_slices, 8);
|
||||
assert_eq!(cfg.slice_overlap, 0.2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
Reference in New Issue
Block a user