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:
MechaCat02
2026-06-13 22:36:23 +02:00
parent ab9b8fb172
commit 85d65f5eda
6 changed files with 744 additions and 167 deletions

View File

@@ -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]