-
Notifications
You must be signed in to change notification settings - Fork 0
technical design.phase4 predetect
Status: Prototype validated — implementation in progress Created: 2026-04-04
Updated: 2026-04-07 — prototypeprototypes/dbnet-testvalidated algorithm and defaults; dilation step added to §4.3; Cargo.toml deps corrected (ort 2.0.0-rc.10, imageproc, no ndarray/half); factory signature extended with dilation/pad params Related:planning.mdM7 / M7b,technical-design.md§3,lenzu/src/ocr/text_detection.rs
Every Shift+Click capture sends the full lens image (400×400 px default) to the Vision-AI endpoint (Gemini 2.0 Flash via OpenRouter). The LLM receives pixel data it doesn't need — backgrounds, UI chrome, whitespace — and must do spatial reasoning that a cheap local model can do faster and for free.
Cost model (approximate)
| Image size | PNG bytes (base64) | ~tokens charged |
|---|---|---|
| 400×400 (current lens) | ~60 KB | ~800–1,200 |
| 120×60 cropped bubble | ~4 KB | ~50–80 |
| Savings (1 bubble found) | ~93% fewer tokens |
With multiple text regions per capture the savings compound. At current Gemini Flash pricing (~$0.10/M input tokens) the absolute cost is low, but pre-detection also improves accuracy — sending a tight crop removes distracting background pixels that can confuse the model on small or dense text.
Shift+Click
→ capture_x11(lens_rect) → BGRA bytes
→ raw_to_rgb + encode_to_base64
→ POST full image to Vision-AI
→ Vec<TranslationResult> (with top_xy / bot_xy returned by LLM)
Shift+Click
↓
adaptive_capture(cursor_pos, lens_size, cfg):
→ capture_x11(cursor, oversample_size × oversample_size) ← single X11 call
→ TextDetector::detect(oversample_image) → primary_boxes ← N separate regions
├─ if no boxes near cursor: return full lens_size crop (Phase 1 fallback)
└─ return oversample_image + primary_boxes + screen_origin
↓
TextCropper::crop(oversample_image, primary_boxes) → N CroppedRegion structs
↓
for each CroppedRegion: ← N OCR calls
OCR(crop, simplified_per_region_prompt) ← no coord fields needed
top_xy / bot_xy = DBNet box (precise) ← NOT guessed by LLM
remap coords to screen space via screen_origin
↓
Vec<TranslationResult> (one entry per DBNet region, N entries total)
→ HUD / clipboard (unchanged)
↑ fallback when N=0: full lens_size crop → full prompt (Phase 1 behaviour)
Ctrl+Shift+Click (full-desktop mode — see §4.8)
↓
hide_lens_window()
→ capture_x11(0, 0, screen_w, screen_h)
show_lens_window()
→ TextDetector::detect(downscaled_to_640)
→ find primary_boxes nearest to lens window position
→ TextCropper::crop → per-region OCR (force-remote backend)
The HUD and clipboard layers are unchanged.
Why per-region is the primary path (not union crop):
DBNet detects each text instance independently — a speech bubble, a subtitle line, a vertical character name, and a sound effect each get their own bounding box. Sending them as a union crop still gives the LLM multiple regions at once; it may still:
- Merge a horizontal subtitle with a nearby vertical name
- Combine handwritten sound effects with dialogue
- Return wrong reading order for mixed vertical/horizontal layouts
Per-region sends one tight crop per DBNet box. The LLM cannot combine what it cannot see.
Prompt simplification enabled by DBNet:
When the LLM receives a single-region crop, it no longer needs to do spatial reasoning. The prompt loses the coordinate fields entirely — bounding boxes come from DBNet, not from the LLM. See §2.3 below.
DBNet outputs a per-pixel probability map. Every connected region above the threshold becomes a separate contour → separate bounding box. A single capture image containing one speech bubble, one subtitle, and one sound effect produces three independent bounding boxes from a single DBNet inference call.
This is the fundamental capability the pipeline relies on:
| Text layout | DBNet output | LLM receives |
|---|---|---|
| Single horizontal line | 1 box | 1 crop |
| Three horizontal lines with gaps | 3 boxes | 3 crops (in order) |
| Vertical Japanese column | 1 box (tall, narrow) | 1 crop |
| Mixed: horizontal subtitle + vertical name label | 2 boxes | 2 crops (separate calls) |
| Handwritten sound effects + dialogue bubble | 2+ boxes | 2+ crops (each isolated) |
| Single large blob with no gaps | 1 box | 1 crop (union of connected region) |
What this replaces in the LLM prompt:
Current full-image prompt asks the LLM to perform three tasks simultaneously:
- Find all text regions (spatial reasoning — where is the text?)
- Read each region (OCR)
- Separate them properly (segmentation — which pixels belong to which text instance?)
With DBNet handling task 1 and task 3, the per-region prompt only asks:
- Read this one region (OCR)
- Translate it
Simplified per-region prompt (replaces TRANSLATE_PROMPT in config.rs when text_detection_model is set):
"Act as a highly accurate {src}-to-{dest} OCR and translation engine.
The image contains exactly one text region. Extract the text and translate it.
Return a JSON object with these fields:
'original' (string — the exact text as written),
'debug_info' (string or null).
{extra_prompt}"
The top_xy / bot_xy fields are removed from the per-region prompt — they come from DBNet, not the LLM. TranslationResult.top_xy and bot_xy are populated in Rust from CroppedRegion.source_box remapped to screen coordinates, before the LLM result is even parsed. The coerce_to_opt_string deserializer complexity (which exists to handle LLMs returning [x,y] arrays instead of "x,y" strings) becomes irrelevant for the per-region path.
The translate_extra_prompt field (e.g., the furigana/romaji extension for Japanese) continues to be appended as {extra_prompt} — no change to config.
Fallback prompt (N=0, full-image path): unchanged — the existing TRANSLATE_PROMPT with top_xy/bot_xy coord fields continues to be used when DBNet finds no regions.
| File | Size | Type | Suitable for text detection? |
|---|---|---|---|
assets/yolov8n_fp16.onnx |
~6 MB | YOLOv8-nano, fp16 | No — COCO 80 classes, no text class |
assets/model_fp16.onnx |
~6 MB | YOLOv8 variant, fp16 | No — COCO 80 classes, no text class |
assets/stabrise-text_detection_dbnet_ml_v02_model.onnx |
~4.7 MB | DBNet text detector | Yes — purpose-built for text |
Why COCO YOLOv8 was ruled out: All YOLO models in the repo are trained on the 80-class COCO dataset, which has no text, speech_bubble, or manga_panel class. Using book (id 73) as a proxy was considered and rejected — it fires on background objects, misses inline text and speech bubbles entirely, and produces bounding boxes at the wrong granularity (one box per book spine, not per text region). Fine-tuning YOLOv8 on a manga dataset (Manga109 balloons) would fix this, but that requires a training pipeline and labelled data that doesn't exist yet.
| Option | Accuracy on manga text | Size | Latency (CPU) | Confidence Scoring | Notes |
|---|---|---|---|---|---|
| DBNet (ONNX) — chosen | High | ~5 MB | ~50–120 ms | Per-box 0–100% (mean prob of thresholded pixels) | Purpose-built text detector; model already in assets |
| YOLOv8n fine-tuned on manga | High | ~6 MB | ~30–80 ms | Per-box objectness score | Requires training pipeline; no dataset yet |
| CRAFT (ONNX export) | High | ~30 MB | ~200–400 ms | Per-pixel affinity score | Character-level; larger and slower than needed |
| EAST text detector | Medium | ~90 MB | ~150–300 ms | Per-box score | Too large for edge deployment |
| Generic YOLOv8n (COCO) | Low — disqualified | 6 MB | ~30 ms | N/A | Wrong training domain; see above |
StabRise/text_detection_dbnet_ml_v0.2 is a DBNet-based model exported to ONNX, specifically trained for multi-language text detection. Key properties:
- Input:
[1, 3, 640, 640]float32 tensor, ImageNet-normalized - Output:
[1, 1, 640, 640]probability map — each pixel is the likelihood it belongs to a text region - Post-processing: threshold the probability map → binary mask → contour extraction → bounding boxes
- No NMS required (DBNet produces a smooth probability map, not discrete anchor proposals)
- The model file is already committed at
assets/stabrise-text_detection_dbnet_ml_v02_model.onnx - The model path is configured in
lenzu_config.json(text_detection_model) — not hard-coded
ImageNet normalization constants used during inference:
| Channel | Mean | Std |
|---|---|---|
| R | 123.675 | 58.395 |
| G | 116.28 | 57.12 |
| B | 103.53 | 57.375 |
[features]
default = []
onnx = ["ort", "imageproc"]
[dependencies]
# existing deps unchanged ...
anyhow = "1.0"
ort = { version = "2.0.0-rc.10", features = ["ndarray"], optional = true }
imageproc = { version = "0.25", optional = true }Build with: cargo build -p lenzu --features onnx
Note — why not
ndarray/halfas separate deps?
The prototype (prototypes/dbnet-test) usesort::value::Tensor::from_arraywith a rawVec<f32>rather than anndarray::Array4, sondarrayandhalfare not required as direct dependencies. Theortcrate's ownndarrayfeature is kept to enable its internal ndarray integration.
/// A bounding box in the coordinate space of the captured image (pixels).
#[derive(Debug, Clone)]
pub struct OcrRect {
pub x1: u32, // left
pub y1: u32, // top
pub x2: u32, // right
pub y2: u32, // bottom
pub confidence: f32,
}
impl OcrRect {
/// Expand rect by `pad` pixels, clamped to image bounds.
pub fn padded(&self, pad: u32, img_w: u32, img_h: u32) -> Self {
OcrRect {
x1: self.x1.saturating_sub(pad),
y1: self.y1.saturating_sub(pad),
x2: (self.x2 + pad).min(img_w),
y2: (self.y2 + pad).min(img_h),
confidence: self.confidence,
}
}
pub fn width(&self) -> u32 { self.x2 - self.x1 }
pub fn height(&self) -> u32 { self.y2 - self.y1 }
/// Map rect coords from 640×640 model space back to original image dimensions.
pub fn scale_to(&self, orig_w: u32, orig_h: u32) -> Self {
OcrRect {
x1: (self.x1 * orig_w / 640).min(orig_w),
y1: (self.y1 * orig_h / 640).min(orig_h),
x2: (self.x2 * orig_w / 640).min(orig_w),
y2: (self.y2 * orig_h / 640).min(orig_h),
confidence: self.confidence,
}
}
}The module exposes a trait so the backend can be swapped without changing callers:
/// Axis-aligned bounding box in the coordinate space of the *original* captured image.
pub struct TextBoundingBox {
pub x1: u32, // left edge
pub y1: u32, // top edge
pub x2: u32, // right edge
pub y2: u32, // bottom edge
}
/// Swappable text-detection backend.
pub trait TextDetector: Send + Sync {
fn detect(&self, image: &DynamicImage) -> Vec<TextBoundingBox>;
}DbNetDetector — DBNet-specific algorithm:
- Record the original
(width, height)for later scaling. - Resize to 640×640 using
FilterType::Triangle(bilinear — faster than Lanczos3, adequate for detection). - For each pixel
(x, y):
input[0, c, y, x] = (channel_value − mean[c]) / std[c]
using ImageNet constants (R/G/B mean[123.675, 116.28, 103.53], std[58.395, 57.12, 57.375]).
Shape:[1, 3, 640, 640]float32. - Run ONNX session; output is
[1, 1, 640, 640]probability map (prob_map[[0, 0, y, x]]). - Threshold: for each pixel,
if prob >= threshold { 255 } else { 0 }→ build aGrayImagemask. -
Dilation (if
dilation > 0):imageproc::morphology::dilate(&mask, Norm::L1, dilation).
Merges nearby character blobs and compensates for DBNet's slightly-shrunk training targets. The radius is in 640×640 pixels; default 16 ≈ 2.5% of image width. -
imageproc::contours::find_contours::<u32>(&mask)→Vec<Contour<u32>>. - Filter: skip contours with fewer than 4 points (stray noise).
- For each surviving contour, compute
min_x, min_y, max_x, max_yof its.points. Also skip contours where(max_x - min_x) < 5 || (max_y - min_y) < 5(single-pixel noise). - Scale back:
x_orig = (x_640 as f32 * scale_x).round() as u32
wherescale_x = orig_width as f32 / 640.0(same for y). - Apply padding: expand by
pad_x/pad_yon each side, clamped to image bounds:
x1 = x1.saturating_sub(pad_x); x2 = (x2 + pad_x).min(orig_w);(same for y). - Collect into
Vec<TextBoundingBox>; run union-merge to collapse overlapping boxes. - Sort top-to-bottom, left-to-right (
y1ascending, thenx1).
Why axis-aligned AABB from contour min/max?
The downstream consumers (TextCropper and the HUD overlay top_xy/bot_xy) both use axis-aligned rectangles. Computing the convex hull or rotated minimum bounding box would add complexity without benefit here; for dense manga text the AABB is tight enough.
Factory function (used by main.rs):
/// Returns None if model_path is None (pre-detection disabled).
/// Returns Err if the model file cannot be loaded.
pub fn build_text_detector(
model_path: Option<&str>,
threshold: f32,
dilation: u8,
pad_x: u32,
pad_y: u32,
) -> anyhow::Result<Option<Box<dyn TextDetector>>>The DbNetDetector struct and its TextDetector impl are #[cfg(feature = "onnx")].
build_text_detector is always present: without the feature it always returns Ok(None) and logs a warning if model_path is Some.
Primary OCR path: DBNet produces N bounding boxes; TextCropper turns them into N individual crops, each of which is sent to the LLM as a separate call with the simplified per-region prompt (§2.3). The LLM receives one region and cannot combine it with anything else.
A separate struct responsible for taking a detected Vec<TextBoundingBox> and producing
padded, validated image crops ready for OCR. Keeping it separate from TextDetector means
the crop logic can be tested and tuned independently of the detection model.
pub struct CroppedRegion {
/// The cropped sub-image, ready for base64 encoding and OCR.
pub image: DynamicImage,
/// Top-left corner of this crop in original image coordinates.
/// Used by `remap_coords` to translate LLM-reported positions back to lens space.
pub origin_x: u32,
pub origin_y: u32,
/// The source bounding box *before* padding was applied.
pub source_box: TextBoundingBox,
}
pub struct TextCropper {
/// Extra pixels added on all four sides of each bounding box.
/// Default: 8 px. Larger values include more context for the LLM.
pub pad: u32,
/// Skip crops with area (width × height) below this pixel threshold.
/// Default: 256 px² (16×16). Prevents sending furigana / single-character noise.
pub min_area: u32,
}
impl TextCropper {
pub fn new(pad: u32, min_area: u32) -> Self;
/// Crop `image` at each bounding box, apply padding (clamped to image bounds),
/// filter by `min_area`, and return the surviving crops.
pub fn crop(&self, image: &DynamicImage, boxes: &[TextBoundingBox]) -> Vec<CroppedRegion>;
}Padding and clamping:
padded_x1 = box.x1.saturating_sub(self.pad)
padded_y1 = box.y1.saturating_sub(self.pad)
padded_x2 = (box.x2 + self.pad).min(image_width)
padded_y2 = (box.y2 + self.pad).min(image_height)
crop_w = padded_x2 - padded_x1
crop_h = padded_y2 - padded_y1
image.crop_imm(padded_x1, padded_y1, crop_w, crop_h) does the actual slice.
Area filter: crop_w * crop_h < self.min_area → skip.
origin_x / origin_y: set to padded_x1 / padded_y1 (the crop's top-left in original image space), not to box.x1/box.y1. The padded origin is what's needed for remap_coords.
Current thread (around line 504–520):
// CURRENT
let results = client.call_api(&b64_full_image)?;Phase 4 replacement — using TextDetector + TextCropper:
// PHASE 4 (feature-gated)
#[cfg(feature = "onnx")]
let results = {
let boxes = dual.detect_text(&dyn_image); // calls TextDetector if configured
if boxes.is_empty() {
// No regions found — send full image (same as current behaviour)
dual.call_api(&dyn_image)?
} else {
let cropper = TextCropper::new(/* pad */ 8, /* min_area */ 256);
let crops = cropper.crop(&dyn_image, &boxes);
let mut all = Vec::new();
for crop in crops {
let mut sub = dual.call_api(&crop.image)?;
// Remap LLM-reported coords from crop-local space → lens space
for t in &mut sub.0 {
remap_coords(t, crop.origin_x, crop.origin_y);
}
all.extend(sub.0);
}
all
}
};
#[cfg(not(feature = "onnx"))]
let results = dual.call_api(&dyn_image)?;dual.detect_text is a thin helper on DualOcrClient that returns vec![] when no detector is configured, keeping the feature-gate at one call site.
DualOcrClient (in client.rs) gains an optional detector field:
pub struct DualOcrClient {
// ...existing fields...
text_detector: Option<Box<dyn TextDetector + Send + Sync>>,
}DualOcrClient::new gains one extra parameter:
text_detector: Option<Box<dyn TextDetector + Send + Sync>>DualOcrClient::detect_text is a thin delegate:
pub fn detect_text(&self, image: &DynamicImage) -> Vec<TextBoundingBox> {
self.text_detector
.as_ref()
.map(|d| d.detect(image))
.unwrap_or_default()
}The detector is constructed in main.rs at startup via build_text_detector:
let text_detector = ocr::text_detection::build_text_detector(
config.text_detection_model.as_deref(),
config.text_detection_threshold,
)?;Model path resolution: the value comes directly from lenzu_config.json → text_detection_model.
Recommended default value: "assets/stabrise-text_detection_dbnet_ml_v02_model.onnx" (relative to CWD).
None / absent field → pre-detection disabled, fall back to full-image OCR.
The lens window displays lens_size × lens_size pixels. When the user Shift+Clicks, we capture exactly that area and run DBNet on it. If the text under the cursor extends beyond the lens boundary — a long subtitle, a wide banner, a tall manga page column — DBNet will detect boxes that clip the capture edge. We now know text continues past the boundary, but we do not know by how much.
This is a chicken-and-egg problem: to know the full text extent you need to capture it, but to know how much to capture you need to know the extent.
| Approach | How it works | Pros | Cons |
|---|---|---|---|
| Iterative expand (previous design, now dropped) | Detect clipping edges → expand by the overhang amount → re-capture → repeat | Minimal over-capture when text is small | Multiple X11 round-trips; expansion amount is a guess; text may still clip after one expansion |
| Full desktop capture | Capture entire screen; run DBNet at reduced resolution; find text near cursor | Catches text of any size | Heavy X11 bandwidth (≥8 MB per click); DBNet on a downscaled full screen loses detection resolution; privacy concern |
| User click-and-drag (xfce4-screenshooter style) | User manually draws the capture rectangle | Exact; no ambiguity | Requires user effort; breaks "just Shift+Click" flow; reference: https://gitlab.xfce.org/apps/xfce4-screenshooter |
| Fixed oversample — chosen | Always capture a larger fixed area; run DBNet once; filter boxes near cursor | Single X11 capture; single DBNet run; simple; configurable ceiling | Over-captures on every click; text larger than the oversample ceiling still clips (handled by fallback) |
Always capture oversample_size × oversample_size centered on the cursor, regardless of whether the text looks like it will fit inside the lens. Run DBNet once on this larger image. The lens window is not shown at this size — the oversample exists only as data for detection.
Why the oversample size is max(lens_size × factor, 640):
DBNet always resizes its input to 640×640 before inference. If we capture at 400×400 (the default lens), we are upscaling to 640×640 — which degrades detection quality. Capturing at ≥640 px in each axis means DBNet is downscaling, which is always better. The factor (default 2.0) means the oversample at a 400px lens is 800×800, giving DBNet a slightly downscaled 640×640 input and 2× the spatial context.
Finding the right text cluster ("primary cluster"): The oversample area may contain multiple unrelated text regions (subtitles from another window, desktop icons, other speech bubbles). We only want the text the user actually clicked on.
Selection rule: keep only boxes that intersect or touch the original lens_size × lens_size area centered on the cursor.
lens_rect = { x: (oversample_w - lens_size) / 2,
y: (oversample_h - lens_size) / 2,
w: lens_size, h: lens_size }
primary_boxes = boxes where box.intersects(lens_rect)
A box "intersects" the lens rect when any part of it overlaps the original lens area — it does not have to be fully contained. This means:
- Small text fully inside the original lens → selected
- Text that started inside the lens and extended past the edge → selected (the oversample captured the extension)
- Unrelated text elsewhere in the oversample area → excluded
If primary_boxes is empty (DBNet found nothing near the cursor), fall back to the full lens-sized center crop of the oversample — same as the current no-detection behaviour.
When text still clips the oversample boundary:
If the union of primary_boxes still touches the edge of the oversample image, the text is larger than oversample_size. This is the case the design cannot automatically handle.
Behaviour: send the union crop (even if text is cut off) and log [adaptive] text exceeds oversample boundary — partial capture. The user can increase text_detection_oversample_factor in config, or use the deferred manual selection feature (see below).
/// The final image + metadata produced by adaptive_capture().
pub struct AdaptiveCaptureResult {
/// Cropped image to send to the OCR backend.
/// Sized to the primary text cluster union, padded and clamped.
pub image: DynamicImage,
/// Top-left corner of `image` in screen coordinates.
/// Subtracted from cursor position and added to LLM-reported coords by remap_coords.
pub screen_origin_x: i32,
pub screen_origin_y: i32,
/// Detected text boxes inside `image` (image-local coordinates).
/// Empty iff DBNet found nothing near the cursor.
pub text_boxes: Vec<TextBoundingBox>,
/// True if the union touched the oversample boundary (text may be cut off).
pub clipped: bool,
}
/// Loaded from AppConfig; controls the oversample and crop behaviour.
pub struct AdaptiveCaptureConfig {
/// Oversample multiplier applied to lens_size in each axis.
/// The actual capture size is max(lens_size * factor, 640), clamped to max_capture_size.
/// Default: 2.0
pub oversample_factor: f32,
/// Hard ceiling on oversample dimension (width or height). Default: 1600.
pub max_capture_size: u32,
/// Padding (px) added around the union crop on all sides. Default: 16.
pub crop_padding: u32,
}pub fn compute_union_bbox(boxes: &[TextBoundingBox]) -> Option<TextBoundingBox> {
let x1 = boxes.iter().map(|b| b.x1).min()?;
let y1 = boxes.iter().map(|b| b.y1).min()?;
let x2 = boxes.iter().map(|b| b.x2).max()?;
let y2 = boxes.iter().map(|b| b.y2).max()?;
Some(TextBoundingBox { x1, y1, x2, y2 })
}DBNet occasionally splits a single physical text region into multiple overlapping bounding boxes — most commonly when vertical Japanese columns produce per-character probability blobs, or when uneven probability mass causes a line to fragment after thresholding. After padding is applied these fragments often overlap. The union merge pass collapses them before boxes are returned to the caller.
Algorithm: iteratively scan all box pairs; when two boxes overlap, replace them with their axis-aligned bounding union (upper-left-most corner, bottom-right-most corner). Repeat until a full pass produces zero merges. Typically converges in 1–2 passes for the number of boxes DBNet produces.
fn overlaps(a: &TextBoundingBox, b: &TextBoundingBox) -> bool {
a.x1 < b.x2 && a.x2 > b.x1 && a.y1 < b.y2 && a.y2 > b.y1
}
fn union_bbox(a: &TextBoundingBox, b: &TextBoundingBox) -> TextBoundingBox {
TextBoundingBox {
x1: a.x1.min(b.x1),
y1: a.y1.min(b.y1),
x2: a.x2.max(b.x2),
y2: a.y2.max(b.y2),
}
}
pub fn merge_overlapping_boxes(mut boxes: Vec<TextBoundingBox>) -> Vec<TextBoundingBox> {
loop {
let mut merged = Vec::with_capacity(boxes.len());
let mut consumed = vec![false; boxes.len()];
let mut any = false;
for i in 0..boxes.len() {
if consumed[i] { continue; }
let mut current = boxes[i].clone();
for j in (i + 1)..boxes.len() {
if consumed[j] { continue; }
if overlaps(¤t, &boxes[j]) {
current = union_bbox(¤t, &boxes[j]);
consumed[j] = true;
any = true;
}
}
merged.push(current);
}
boxes = merged;
if !any { break; }
}
boxes
}This should be called at the end of DbNetDetector::detect(), after padding is
applied and before the result is returned. It is distinct from compute_union_bbox
(which folds all boxes into one for the oversample crop path); this function only
merges pairs that actually overlap and leaves well-separated boxes independent.
fn adaptive_capture(
detector: &dyn TextDetector,
cursor_screen_x: i32,
cursor_screen_y: i32,
lens_size: u32,
cfg: &AdaptiveCaptureConfig,
) -> AdaptiveCaptureResult
- Compute
oversample_size = clamp(lens_size as f32 * cfg.oversample_factor, 640.0, cfg.max_capture_size as f32) as u32 -
os_screen_x = cursor_screen_x - (oversample_size / 2) as i32os_screen_y = cursor_screen_y - (oversample_size / 2) as i32 oversample_image = capture_x11(os_screen_x, os_screen_y, oversample_size, oversample_size)all_boxes = detector.detect(&oversample_image)- Compute
lens_rectin oversample-local coords:lens_x = (oversample_size - lens_size) / 2lens_y = (oversample_size - lens_size) / 2 primary_boxes = all_boxes.filter(|b| b.intersects(lens_x, lens_y, lens_size, lens_size))- If
primary_boxesis empty: → crop oversample to the centrallens_size × lens_sizearea (original lens) → return with emptytext_boxes,clipped = false -
union = compute_union_bbox(&primary_boxes)(alwaysSomehere) - Detect boundary clip:
clipped = union touches any edge of oversample_image - Pad and clamp union:
px1 = union.x1.saturating_sub(cfg.crop_padding) py1 = union.y1.saturating_sub(cfg.crop_padding) px2 = min(union.x2 + cfg.crop_padding, oversample_size) py2 = min(union.y2 + cfg.crop_padding, oversample_size) - Minimum size guard: if
(px2 - px1) < 16 || (py2 - py1) < 16→ use central lens_size crop crop = oversample_image.crop_imm(px1, py1, px2 - px1, py2 - py1)-
screen_origin_x = os_screen_x + px1 as i32screen_origin_y = os_screen_y + py1 as i32 - Return
AdaptiveCaptureResult { image: crop, screen_origin_x, screen_origin_y, text_boxes: primary_boxes, clipped }
Note: adaptive_capture takes the cursor position and calls capture_x11 internally. It does not receive a pre-captured image — that was a design mistake in the earlier draft. The oversample must be the very first capture; there is no "initial lens capture" anymore when detection is enabled.
For text that exceeds max_capture_size, the automatic approach cannot help. The correct long-term solution is a manual selection mode, as used by xfce4-screenshooter (https://gitlab.xfce.org/apps/xfce4-screenshooter): the user holds Shift and drags a rectangle rather than clicking a point. Lenzu would enter a "selection mode" overlay where the cursor becomes a crosshair, and the capture area is defined by the drag rect.
This is deferred — it requires significant GTK3 interaction changes (draw-on-drag, crosshair cursor). Until it is implemented, users with consistently large text should increase text_detection_oversample_factor or text_detection_max_capture_size in config.
adaptive_capture replaces the capture_x11 call + DBNet path in the main.rs background thread when text_detection_model is set:
// With detection enabled:
let result = adaptive_capture(
&*detector,
cursor_screen_x, cursor_screen_y,
config.lens_size as u32,
&adaptive_cfg,
);
if result.clipped {
eprintln!("[adaptive] text exceeds oversample boundary — partial capture");
}
let (mut ocr_results, meta) = dual.call_api(&result.image)?;
for t in &mut ocr_results {
remap_coords(t, result.screen_origin_x, result.screen_origin_y);
}
// Without detection (text_detection_model = null):
// capture_x11 at lens_size as before, no adaptive logic.No change to DualOcrClient::call_api signature.
The oversample approach (§4.7) has a hard ceiling (max_capture_size, default 1600 px). Text blocks larger than that — long subtitles running the full screen width, multi-panel manga spreads — cannot be auto-sized. The user needs a way to say "go wider than the normal oversample and find the text yourself".
Ctrl+Shift+Click currently forces the remote OCR backend (call_api_force_fallback). That behaviour is retained and stacked: Ctrl+Shift+Click = full-desktop DBNet capture and force-remote OCR.
DBNet always resizes its input to 640×640 before inference. Whether the input image is 800×800 or 1920×1080, inference time is the same (~50–120 ms CPU). The extra cost is only in the X11 GetImage call (typically 20–60 ms for a full 1080p desktop). This is acceptable for a deliberate Ctrl+Shift+Click; it would be too slow for every Shift+Click.
At the moment of Ctrl+Shift+Click, the lens window is visible on screen. X11 GetImage on the root window captures the composited display — including the lens window's own pixels. If the lens is sitting over the text the user wants to OCR, the captured image will contain the lens frame, the spinner, or the previous OCR result, not the text underneath.
Solution: hide the lens window before the full-desktop capture, show it again immediately after.
GTK3 provides widget.hide() / widget.show(). Between hide() and the X11 GetImage there must be a compositor sync — at minimum one gdk::flush() + a brief yield — so the compositor has time to re-paint the desktop without the lens overlay before we snapshot it.
Timing requirement: two frames (at 60 Hz, ~33 ms) is enough for most compositors. A std::thread::sleep(Duration::from_millis(50)) before capture is a simple and reliable guard.
on Ctrl+Shift+Click:
1. Record current lens position (screen_center_x, screen_center_y)
2. hide_lens_window() // GTK: window.hide(); gdk::flush()
3. sleep(50ms) // allow compositor to repaint
4. full_capture = capture_x11(0, 0, screen_w, screen_h)
5. show_lens_window() // GTK: window.show()
6. downscale full_capture to 640×640 for DBNet input
(keep the full-res copy; DBNet receives the downscaled version)
7. boxes = detector.detect(&downscaled) // returns boxes in 640×640 space
8. scale boxes back to full-screen coordinates:
box_screen.x1 = (box_640.x1 as f32 / 640.0 * screen_w as f32) as u32
(same for y, x2, y2)
9. primary_boxes = boxes where centroid distance to lens center < selection_radius
(selection_radius default: lens_size / 2; configurable)
10. if primary_boxes empty → use lens_size crop centered on cursor (fallback)
11. union = compute_union_bbox(&primary_boxes)
12. crop full_capture to union + crop_padding (clamp to screen bounds)
13. screen_origin = (union.x1 - crop_padding, union.y1 - crop_padding)
14. call_api_force_fallback(&crop) // force remote OCR, existing behaviour
15. remap_coords(results, screen_origin_x - screen_center_x + lens_size/2,
screen_origin_y - screen_center_y + lens_size/2)
Step 6 is a new downscale_for_detection(image: &DynamicImage) -> DynamicImage helper in utils.rs — it resizes to 640×640 using FilterType::Triangle (same as DBNet preprocessing), producing the image the detector should see. The full-res image is kept in a separate binding for the final crop in step 12.
screen_w and screen_h are available at startup from GTK's default screen:
let screen = gdk::Screen::default().expect("no default screen");
let screen_w = screen.width() as u32;
let screen_h = screen.height() as u32;Multi-monitor note: gdk::Screen::width() returns the combined virtual desktop width. On a dual-monitor setup this captures both monitors. This is correct behaviour — text may be on either monitor.
Before this change, Ctrl+Shift+Click was the only way to force the remote backend when the local ollama is slow or wrong. With this change, Ctrl+Shift+Click implies full-desktop capture plus force-remote. If the user wants force-remote without the full-desktop capture (e.g., text is small and they just want a better LLM), they have no dedicated shortcut.
Options (decide before implementing):
- A: Ctrl+Shift+Click = full-desktop + force-remote (as designed here). Old force-remote-only behaviour is gone or merged.
- B: Ctrl+Shift+Click = full-desktop; Ctrl+Alt+Shift+Click = force-remote-only (adds a third chord).
- C: Ctrl+Shift+Click = full-desktop (force-remote is always implied when the result needs it, via the fallback chain — so the user doesn't need a dedicated force-remote shortcut).
Option C is the cleanest: the existing automatic fallback chain already tries remote when local fails. The manual "force remote" shortcut was a debug convenience. Full-desktop mode for Ctrl+Shift+Click is a more useful assignment.
When text_detection_debug: true is set in config, detected bounding boxes are rendered visually so the developer can verify DBNet is finding the right regions.
A transparent full-screen GTK3 window with drawn rectangles would be the most interactive option but carries the same alpha-compositing / ghosting risk that caused the Tauri→Electron migration (see planning.md — Overlay HUD history). The compositor layer is not a safe place to invest in for a debug-only feature.
The Electron lenzu_server overlay is pinned to the bottom of the screen as a subtitle band — it is not positioned or sized for full-screen annotation.
1. Annotated debug PNG — covers Ctrl+Shift+Click (full-desktop) and Shift+Click alike
After every detection run (regardless of whether boxes were found), save an annotated copy of the detection image:
/dev/shm/lenzu/debug_detection.png
- For Shift+Click: the annotated image is the
oversample_size × oversample_sizecapture with a yellow rectangle marking the original lens region and green rectangles for each detected box. - For Ctrl+Shift+Click: the annotated image is the full desktop capture (downscaled to 640×640, since that is what DBNet actually saw) with green boxes drawn.
Implementation:
// imageproc is already a planned dep (--features onnx)
use imageproc::drawing::draw_hollow_rect_mut;
use imageproc::rect::Rect;
use image::Rgb;
fn save_debug_image(
detection_input: &DynamicImage, // the image DBNet received (640×640 for full-desktop)
boxes: &[TextBoundingBox],
lens_rect: Option<(u32, u32, u32, u32)>, // Some((x,y,w,h)) for Shift+Click only
) {
let mut annotated = detection_input.to_rgb8();
// Yellow rectangle = original lens area (Shift+Click only)
if let Some((x, y, w, h)) = lens_rect {
draw_hollow_rect_mut(&mut annotated, Rect::at(x as i32, y as i32).of_size(w, h), Rgb([255, 255, 0]));
}
// Green rectangles = detected text boxes
for b in boxes {
draw_hollow_rect_mut(&mut annotated, Rect::at(b.x1 as i32, b.y1 as i32).of_size(b.x2 - b.x1, b.y2 - b.y1), Rgb([0, 255, 0]));
}
let _ = std::fs::create_dir_all("/dev/shm/lenzu");
let _ = DynamicImage::ImageRgb8(annotated).save("/dev/shm/lenzu/debug_detection.png");
eprintln!("[debug] detection image saved → /dev/shm/lenzu/debug_detection.png ({} boxes)", boxes.len());
}2. Cairo box overlays on the lens window — covers Shift+Click only
For Shift+Click, after DBNet detects boxes, scale the primary_boxes from oversample space to lens display space and draw them as colored outlines in the lens window's connect_draw callback using the existing Cairo context:
// In the lens window draw callback (main.rs), when debug mode is on:
if config.text_detection_debug {
cr.set_source_rgba(0.0, 1.0, 0.2, 0.8); // green, 80% opacity
cr.set_line_width(2.0);
let scale_x = lens_size as f64 / oversample_size as f64;
let scale_y = scale_x;
let lens_offset_x = (oversample_size - lens_size) / 2;
let lens_offset_y = (oversample_size - lens_size) / 2;
for b in &state.last_detection_boxes {
// Transform: oversample coords → lens-display coords
let x = (b.x1 as i32 - lens_offset_x as i32) as f64 * scale_x;
let y = (b.y1 as i32 - lens_offset_y as i32) as f64 * scale_y;
let w = (b.x2 - b.x1) as f64 * scale_x;
let h = (b.y2 - b.y1) as f64 * scale_y;
cr.rectangle(x, y, w, h);
cr.stroke().unwrap_or(());
}
}state.last_detection_boxes is a Vec<TextBoundingBox> stored in AppState, updated after each Shift+Click detection. Boxes fully outside the original lens rect appear outside the lens window boundary (clipped by GTK).
For Ctrl+Shift+Click: the boxes are in full-screen space and the lens is 400×400 — scaling a 1920×1080 scene into 400×400 makes individual boxes 1–2 px tall. The debug PNG is the right tool here; the Cairo overlay is not added for the full-desktop path.
The annotated debug PNG is the primary debug tool — it works for both Shift+Click and Ctrl+Shift+Click, requires no GTK interaction changes, and gives a complete picture of what DBNet saw and detected. The Cairo overlay in the lens window is a lightweight bonus for Shift+Click; it is not added for the full-desktop path where the scale is impractical.
A real-time transparent overlay drawn over the live screen was considered and rejected: it carries the same alpha-compositing / compositor ghosting risk that caused the Tauri→Electron migration, and the debug PNG already provides all the information needed.
| Debug tool | Shift+Click | Ctrl+Shift+Click | Effort | Status |
|---|---|---|---|---|
Annotated PNG /dev/shm/lenzu/debug_detection.png
|
Yes (oversample image + box outlines) | Yes (640×640 DBNet input + box outlines) | ~20 lines; imageproc already a dep |
Primary; implement first |
| Cairo box overlay on lens window | Yes (boxes scaled to lens display) | No (1920→400 scale makes boxes 1–2 px) | ~15 lines; existing Cairo context | Secondary; easy add-on |
| Transparent full-screen GTK3 overlay | Possible | Possible | High effort; compositor risk | Deferred indefinitely |
Config flag: text_detection_debug: bool (default false). All debug outputs disabled when false.
Note: there is no predetect_iou_threshold for DBNet (IoU/NMS is a YOLO concept — DBNet outputs a smooth probability map, not anchor boxes, so NMS is not needed).
AppConfig in config.rs gains all new fields with #[serde(default)] so existing config files remain valid without any migration.
Prompt changes in config.rs: a second prompt constant is added alongside TRANSLATE_PROMPT:
/// Used when text_detection_model is set and DBNet has found a region.
/// Bounding box is already known from DBNet — coordinates are NOT requested from the LLM.
const PER_REGION_PROMPT: &str =
"Act as a highly accurate {src}-to-{dest} OCR and translation engine. \
The image contains exactly one isolated text region. \
Extract the text and translate it. \
Return a JSON object with EXACTLY these fields: \
'original' (string), \
'debug_info' (string or null). \
Do NOT include top_xy or bot_xy — they are determined externally.";AppConfig::resolved_prompt() gains a boolean parameter (or separate method resolved_per_region_prompt()). When text_detection_model is Some, the per-region prompt is used for individual crop calls; the full TRANSLATE_PROMPT is used only for the fallback full-image path.
translate_extra_prompt (e.g., "…furigana and romaji fields…") is still appended to both prompts.
Every bounding box in the pipeline lives in exactly one of these spaces. Getting the space wrong silently produces off-by-N-× errors that are hard to spot without the debug PNG.
| Space | Size | Origin | Lifetime |
|---|---|---|---|
| DBNet input space | always 640×640 | top-left of resized image | Internal to DbNetDetector::detect() only — never escapes |
| Capture space | oversample size (square) or full desktop (non-square) | top-left of capture_x11 rect |
Output of detect(); input to TextCropper
|
| Screen space | full desktop (e.g. 1920×1080) | (0,0) = monitor top-left |
AdaptiveCaptureResult.screen_origin_*; remap_coords offset |
| Crop space | individual CroppedRegion dimensions |
top-left of padded crop | LLM input; LLM-reported top_xy/bot_xy before remapping |
TextDetector::detect(image: &DynamicImage) -> Vec<TextBoundingBox> always returns boxes in the coordinate space of image, regardless of the internal 640×640 resize. The caller never needs to know the model's input resolution.
This is enforced inside DbNetDetector: after computing contours in 640×640 space, every point is scaled back before returning:
// Inside DbNetDetector::detect, at the end of post-processing:
let scale_x = orig_width as f32 / 640.0;
let scale_y = orig_height as f32 / 640.0;
TextBoundingBox {
x1: (box_640.x1 as f32 * scale_x) as u32,
y1: (box_640.y1 as f32 * scale_y) as u32,
x2: ((box_640.x2 as f32 * scale_x) as u32).min(orig_width),
y2: ((box_640.y2 as f32 * scale_y) as u32).min(orig_height),
}Why separate scale_x and scale_y: for the square oversample capture scale_x == scale_y, so they are interchangeable. For the full-desktop Ctrl+Shift+Click capture (e.g. 1920×1080), scale_x = 3.0 but scale_y = 1.6875. Using a single scale factor here would shift boxes sideways or vertically. The scaling must always use the per-axis ratio.
Aspect ratio distortion: DBNet's resize_exact(640, 640) distorts non-square images. On a 1920×1080 desktop the horizontal axis is compressed more than vertical (ratio 3.0 vs 1.6875). Detection quality may degrade on very wide/narrow text. If this proves problematic, letterboxing can be added: pad the shorter axis to make the image square before resize, then subtract the pad offset when scaling back. For now, accept the distortion — it rarely matters for axis-aligned Latin/CJK text.
capture_x11(os_screen_x, os_screen_y, OS, OS) → oversample image (OS×OS)
└─ OS = max(lens_size × factor, 640), e.g. 800
detector.detect(&oversample_image) → boxes in OS×OS space
└─ internally: resize to 640×640 → contours → scale back by OS/640
TextCropper::crop(oversample_image, boxes) → CroppedRegion
└─ origin_x, origin_y in OS×OS space
screen_origin_x = os_screen_x + origin_x → screen space
screen_origin_y = os_screen_y + origin_y
LLM receives crop image (crop space)
LLM-reported top_xy "cx,cy" is in crop space
remap: screen_x = screen_origin_x + cx → screen space
screen_y = screen_origin_y + cy
DBNet-provided top_xy (primary path, §2.3):
box in OS space → subtract origin_x/y → crop space
"cx,cy" = (box.x1 - origin_x, box.y1 - origin_y)
(no LLM coordinate guessing needed)
capture_x11(0, 0, screen_w, screen_h) → full desktop image (W×H, e.g. 1920×1080)
detector.detect(&full_desktop_image) → boxes in W×H space
└─ internally: resize to 640×640 → contours → scale back by W/640, H/640
Filter: keep boxes where centroid is within selection_radius of cursor
TextCropper::crop(full_desktop_image, boxes) → CroppedRegion
└─ origin_x, origin_y in W×H space = screen space (capture origin is (0,0))
screen_origin_x = 0 + origin_x = origin_x → screen space
screen_origin_y = 0 + origin_y = origin_y
LLM receives crop (crop space); remap same as above
Because the full-desktop capture starts at (0,0), capture space == screen space for Ctrl+Shift+Click. No extra offset addition.
The lens window displays the captured area at lens_size × lens_size pixels, but the underlying data is OS × OS. To draw DBNet boxes on the lens window:
let scale = lens_size as f64 / oversample_size as f64; // e.g. 400/800 = 0.5
let offset_x = (oversample_size - lens_size) / 2; // center of oversample
let offset_y = (oversample_size - lens_size) / 2;
// For each box (in OS space):
let display_x = (box.x1 as i32 - offset_x as i32) as f64 * scale;
let display_y = (box.y1 as i32 - offset_y as i32) as f64 * scale;
let display_w = (box.x2 - box.x1) as f64 * scale;
let display_h = (box.y2 - box.y1) as f64 * scale;Boxes outside the original lens area produce negative display_x/display_y — they are clipped by the lens window boundary automatically. This correctly shows that part of a text region extended past the lens edge.
When DBNet is active (per-region path): the LLM receives a single tight crop and is not asked for coordinates at all (§2.3 prompt has no top_xy/bot_xy fields). Bounding boxes are set in Rust directly from the DBNet box scaled to screen space. remap_coords is not called on this path.
When DBNet is not active (fallback full-image path): the LLM receives the full lens image and is asked to report top_xy/bot_xy in crop space. remap_coords translates those to screen space.
/// Translate LLM-reported top_xy / bot_xy from crop space to screen space.
/// offset_x = screen_origin_x, offset_y = screen_origin_y of the crop.
/// Only called on the fallback full-image path — NOT on the per-region DBNet path.
pub fn remap_coords(result: &mut TranslationResult, offset_x: i32, offset_y: i32) {
result.top_xy = add_offset(&result.top_xy, offset_x, offset_y);
result.bot_xy = add_offset(&result.bot_xy, offset_x, offset_y);
}
fn add_offset(xy: &Option<String>, dx: i32, dy: i32) -> Option<String> {
let s = xy.as_deref()?;
let (x, y) = parse_xy(s)?; // reuse existing parse_xy from client.rs
Some(format!("{},{}", x + dx, y + dy))
}remap_coords is only called on the fallback full-image path (when DBNet finds nothing and the LLM reports its own coordinate guesses). On the per-region path, top_xy/bot_xy are populated directly in Rust from the DBNet box, already in screen space — remap_coords is skipped.
| Condition | Behaviour |
|---|---|
onnx feature not compiled |
Capture at lens_size; send full image (current behaviour) |
text_detection_model is null / absent in config |
Same as above — no adaptive capture |
| Model file not found at startup | Warn to stderr; disable adaptive capture for the session; fall back to lens_size |
detect() returns an error |
Log warning; send central lens_size crop of the oversample image |
| No boxes intersect the original lens rect | No text detected near cursor; send central lens_size crop |
| Union crop smaller than 16×16 px | Degenerate box (noise); send central lens_size crop |
oversample_size clamped to max_capture_size
|
Text may extend past the oversample; log [adaptive] clipped; send best-effort union crop |
Ctrl+Shift+Click (full-desktop mode); detect() finds nothing near lens |
Send full lens_size crop centered on cursor; still force-remote backend |
-
Model accuracy on manga — DBNet (
stabrise-text_detection_dbnet_ml_v02_model.onnx) is trained on multi-language printed text; its accuracy on manga-style hand-lettered text and vertical Japanese columns is unproven. Manual QA is required: enable detection, capture a representative manga page, and verify that detected bounding boxes align with speech bubbles and inline text. If quality is poor, fine-tuning DBNet on Manga109 annotations is the next option. -
Multiple crops vs one merged request — Sending N crops = N API calls = N round trips. Alternative: pack multiple crops into a single request as a multi-image message (check Gemini API multi-image support). Could reduce latency at the cost of more complex parsing.
-
Vertical text direction — DBNet bbox orientation is axis-aligned; vertical Japanese columns may be merged into one tall narrow box. Verify this works well with how Gemini handles narrow vertical crops.
-
Minimum crop size threshold — Very small rects (furigana, single kanji) may produce worse results than the full image. Tune
text_detection_thresholdandTextCropper::min_areaempirically. -
Gemma 4 E2B / E4B as on-device OCR backend — See §11 below.
-
ocr/mod.rscurrently declares non-existent modules —image_handling,ocr_gcloud,ocr_tesseract,ocr_traits,ocr_winmediaare all listed but the files don't exist on disk. This causes compile errors. The fix (remove stubs, addtext_detectionandtext_cropper) is tracked in §10.
text_detection.rs
-
compute_union_bbox— empty input returnsNone; single box returns itself; overlapping boxes return their union; non-overlapping boxes span all four extremes -
merge_overlapping_boxes— empty input returns empty; single box returns itself unchanged; two non-overlapping boxes returned as-is; two overlapping boxes collapsed into one union; three-way chain (A∩B, B∩C but not A∩C) collapses all three in second pass
text_cropper.rs (primary OCR dispatch path)
-
TextCropper::crop: emptyboxes→ empty vec; padding applied correctly; clamped at image boundary; boxes belowmin_areafiltered
adaptive_capture.rs (oversample approach)
-
text_fits_in_lens: oversample captures text; union well inside the centrallens_sizerect; crop is tight;screen_originequals oversample top-left + padded union position -
text_larger_than_lens: white rect spans from center to right edge of oversample; union.x2 < oversample edge → included;clipped = false; crop is wider thanlens_size -
text_clips_oversample_edge: white rect touches right edge of oversample image;clipped = true; crop is still returned (best-effort, text is cut off) -
no_boxes_near_cursor: all detected boxes are in corners, outside thelens_sizefilter rect;primary_boxesis empty; returns centrallens_sizecrop -
degenerate_union (< 16×16): falls back to centrallens_sizecrop -
oversample_size_formula:lens_size=400, factor=2.0→oversample=800;lens_size=200, factor=2.0→oversample=640(clamped to DBNet min);lens_size=900, factor=2.0→oversample=1600(clamped to max)
remap_coords
- Positive offset:
"10,20"+ origin(50, 30)→"60,50" - Zero offset: coords unchanged
- Negative offset (crop origin left/above cursor center): correct subtraction
-
Nonetop_xy / bot_xy: not modified (no panic)
config.rs
- Old config without any
text_detection_*fields deserializes with all defaults -
text_detection_model: null→Option::Nonein Rust - Three new adaptive capture fields default to
2.0/1600/16
- Load
stabrise-text_detection_dbnet_ml_v02_model.onnx+ a real capture PNG; verifyDbNetDetector::detect()returnsVec<TextBoundingBox>without panic -
adaptive_capturewith a synthetic 800×800 image containing a white rectangle at center: confirmprimary_boxescontains the box; confirm returned crop is tighter than the full 800×800 - End-to-end with
wiremockstub:text_detection_modelset → union crop posted; size smaller thanlens_size × lens_size;screen_origincorrect
- Shift+Click on a single kanji: confirm OCR receives a tight crop (log shows
[adaptive] crop: WxH) - Shift+Click on a long subtitle that extends beyond the lens but within the oversample: confirm crop is wider than
lens_size,clipped = false - Shift+Click where text exceeds the oversample ceiling: confirm
[adaptive] clippedlog line appears - Shift+Click on empty area: confirm
[adaptive] no text near cursor — using lens croplog line - Ctrl+Shift+Click: confirm lens window hides before capture, full desktop captured, nearest text found, window reappears
-
Fix
ocr/mod.rs— remove the five non-existent stub module declarations; replace withpub mod text_detection;andpub mod text_cropper; -
Add deps to
Cargo.toml:- Always:
anyhow = "1.0" - Under
[features] onnx:ort = { version = "2.0.0-rc.12", features = ["ndarray"] },ndarray = "0.15",imageproc = "0.25" - Build:
cargo build -p lenzu --features onnx(see §11 for why no system libs needed)
- Always:
-
Write
text_detection.rs—TextBoundingBox,compute_union_bbox,TextDetectortrait,DbNetDetector,build_text_detector; unit tests forcompute_union_bbox -
Write
text_cropper.rs—CroppedRegion,TextCropper; unit tests; this is the primary OCR dispatch path (one crop per DBNet box) -
Write
adaptive_capture.rs—AdaptiveCaptureResult,AdaptiveCaptureConfig,adaptive_capture(); unit tests per §9 -
Add config fields — five fields to
AppConfigwith#[serde(default)]per §5 -
Implement
remap_coordsinutils.rs -
Wire Shift+Click path in
main.rs— replacecapture_x11+ encode withadaptive_capture(); addremap_coordscall; handleresult.clippedlog -
Wire Ctrl+Shift+Click path in
main.rs— hide lens → full-desktopcapture_x11→ DBNet nearest-box → show lens → force-remote OCR (per §4.8) -
Implement
save_debug_imageinutils.rs— annotated PNG to/dev/shm/lenzu/debug_detection.png; gated ontext_detection_debug; ~20 lines usingimageproc::drawing(already a planned dep) -
Wire Cairo box overlay in
main.rsdraw callback — storelast_detection_boxes: Vec<TextBoundingBox>inAppState; draw green outlines whentext_detection_debug = true(per §4.9) -
Remove
detect_text()fromDualOcrClient— detection now lives inadaptive_capture.rs; simplifyDualOcrClient::newsignature -
Manual QA — set
text_detection_debug: true; Shift+Click a manga panel; inspect/dev/shm/lenzu/debug_detection.pngand lens window overlays; tunetext_detection_thresholdandoversample_factor
No. The DBNet inference pipeline uses only pure-Rust crates:
| Task | Library | Installed how |
|---|---|---|
| ONNX model inference |
ort (Rust crate wrapping ONNX Runtime C API) |
Cargo downloads ONNX Runtime binaries automatically at build time |
| N-dimensional arrays | ndarray |
Cargo (pure Rust) |
| Resize / pixel access | image |
Cargo (already a dep, pure Rust) |
| Contour finding on binary mask | imageproc |
Cargo (pure Rust) |
OpenCV is a C++ library that provides all of the above plus much more. Since we only need the ONNX inference + image manipulation subset, and Rust crates cover it, OpenCV is not a dependency and does not need to be installed.
The ort crate v2.0.0-rc.12 (same version already used in prototypes/x11-gtk3-lens-test)
downloads the pre-built ONNX Runtime shared library from GitHub releases during the
cargo build step. No apt install is required.
Required for the download to succeed at build time:
- Internet access (for first build; artefact is cached by Cargo after that)
-
libstdc++/glibc— already present on any Debian/Ubuntu system
The cargo build -p lenzu line in setup.sh must pass --features onnx to compile the
detection code. Since the DBNet model is already committed to assets/, we always enable
the feature:
# Was:
cargo build -p lenzu
# Becomes:
cargo build -p lenzu --features onnxThe run.sh script (and any CI build commands) need the same update.
The existing apt install block in setup.sh already covers all system deps. The only
change is the --features onnx flag on the Cargo build invocation.
Google's Gemma 4 (E2B = ~2B params, E4B = ~4B params) is a multimodal vision-language model with:
- Native support for 140+ languages baked into the base weights — not added via post-training patches
- Strong OCR, handwriting recognition, and visual data extraction from images
- Small enough to run fully on-device (CPU or GPU), with no network dependency
- Both variants are open weights, distributable with the application or downloaded on first run
Model IDs and distribution
| Channel | E2B | E4B |
|---|---|---|
| HuggingFace | google/gemma-4-E2B-it |
google/gemma-4-E4B-it |
| Ollama | gemma4:e2b |
gemma4:e4b |
| Kaggle | kaggle.com/models/google/gemma-4 |
same collection |
| GGUF (Unsloth) | 4-bit / 8-bit quantized builds on Unsloth HuggingFace page | same |
The -it suffix denotes the instruction-tuned variant — this is the one to use for lenzu (prompt-following, JSON output).
Gemma 4 E2B/E4B is not just a cheaper remote API — it enables a fully offline pipeline:
Current: DBNet (local) → crop → OpenRouter/Gemini (remote, paid)
With Gemma: DBNet (local) → crop → Gemma 4 E2B (local, free after download)
This satisfies the project's offline-first / privacy-first design goal at the OCR+translation layer, not just the detection layer.
| Dimension | Gemini 2.0 Flash (current) | Gemma 4 E2B | Gemma 4 E4B |
|---|---|---|---|
| Hosting | Remote (OpenRouter) | Local | Local |
| Cost | ~$0.10/M tokens | Free (after download) | Free (after download) |
| Privacy | Images leave device | Images stay on device | Images stay on device |
| Latency | Network + inference | Inference only (~500ms–2s on CPU) | Inference only (~1–4s on CPU) |
| Languages | Strong (Google training) | 140+ native | 140+ native |
| Japanese OCR | Very good | Strong | Strong |
| Furigana / romaji output | Via prompt | Via prompt | Via prompt |
| Model size | N/A | ~1.5 GB (quantized) | ~3 GB (quantized) |
| GPU acceleration | N/A | Vulkan / Metal / CUDA | Vulkan / Metal / CUDA |
Gemma 4 can be served in two ways compatible with the existing client.rs architecture:
Option A — ollama subprocess (lowest integration effort)
- User runs
ollama run gemma4:e2borollama run gemma4:e4bonce to pull the model -
lenzucallshttp://localhost:11434/v1/chat/completions(ollama's OpenAI-compatible endpoint) - Config: set
llm_api_endpointtohttp://localhost:11434/v1/chat/completions,llm_default_modeltogemma4:e2b -
client.rsalready reads endpoint from config — this requires no code change beyond dropping the auth header - Limitation: requires
ollamadaemon running; user must pull model manually (or lenzu can detect and prompt)
Option B — Direct GGUF via llama.cpp (self-contained)
- Download a quantized GGUF from Unsloth's HuggingFace page (4-bit ~1 GB for E2B, ~2 GB for E4B)
- Spawn
llama-serveras a child process alongsidelenzu_server(same lifecycle pattern already used for the Electron HUD) - Exposes the same OpenAI-compatible endpoint as ollama —
client.rsunchanged - Preferable for Flatpak/AppImage distribution where bundling
ollamais impractical
Option C — ONNX export (consistent with YOLO pipeline)
- Export Gemma 4 E2B to ONNX via
optimumand run viaort - Same
ortdependency already used for the DBNet text detector - ONNX VLMs are less mature; multimodal (image) input support varies by export toolchain
- Worth revisiting once the ONNX ecosystem matures, but not the near-term path
-
Note: the export step itself requires Python (
optimum), but the resulting.onnxfile is consumed entirely by Rust (ort). The model file can be pre-exported and shipped; no Python needed at runtime or build time.
Language constraint: no Python
All runtime and build-time code must be Rust or TypeScript. This rules out:
- Any Python subprocess at runtime (no
manga-ocr, nopaddleocrPython bindings, no on-the-flyoptimumexport) - PyTorch-based inference at runtime
It does not rule out:
- GGUF files consumed by
llama.cpp(C++ library, usable from Rust viallama-cpp-rsor as a child process) - ONNX files consumed by
ort(pure Rust) - Ollama HTTP API (language-agnostic REST)
- HuggingFace
candle(pure Rust ML framework — see below)
Option D — HuggingFace candle (pure Rust, no external process)
-
candleis HuggingFace's pure-Rust ML inference framework - Gemma model support exists in
candle-transformers(Gemma 2 confirmed; Gemma 4 support depends on community PRs) - Would allow loading weights from HuggingFace Hub directly in Rust, no child process needed
- Most self-contained option if
candlegains Gemma 4 multimodal support - Track:
github.com/huggingface/candle— check forgemma4orpaligemmamultimodal examples
Decision: Ollama runs as a Docker container managed by the dev scripts.
-
scripts/setup.sh— installs Docker if absent, pullsollama/ollamaimage, createslenzu-ollama-datavolume, pullsgemma4:e2bmodel once -
scripts/run.sh— auto-detects backend: ifOPENROUTER_API_KEYis unset, starts thelenzu-ollamacontainer before launching lenzu and stops it on exit (trap); if key is set, skips ollama entirely and uses OpenRouter as before - GPU passthrough:
--gpus alladded automatically if NVIDIA runtime is detected indocker info - Model storage is persistent across runs via the named Docker volume
lenzu-ollama-data
This requires no code change in client.rs — the user sets llm_api_endpoint to http://localhost:11434/v1/chat/completions and llm_default_model to gemma4:e2b in lenzu_config.json when using local mode.
Future options (not yet pursued):
- Option B (llama.cpp child process): preferable for Flatpak/AppImage where Docker is unavailable
- Option D (candle): pure Rust, no external process — contingent on Gemma 4 multimodal support landing in
candle-transformers
The existing prompt in config.rs::TRANSLATE_PROMPT (JSON array of TranslationResult objects) should work with Gemma 4 without modification — it's instruction-following capable. Verify that top_xy / bot_xy coordinate output is consistent; may need a minor prompt tweak since Gemma 4 was not specifically tuned on this schema.
{
"llm_backend": "openrouter", // "openrouter" | "ollama" | "local"
"llm_api_endpoint": "https://openrouter.ai/api/v1/chat/completions",
"llm_default_model": "google/gemini-2.0-flash-001",
// For ollama backend (gemma4:e2b or gemma4:e4b):
// "llm_api_endpoint": "http://localhost:11434/v1/chat/completions",
// "llm_default_model": "gemma4:e2b"
}The llm_backend field drives auth header behaviour (Bearer <key> for OpenRouter; no auth for local ollama).
Add to planning.md:
- M7b: Gemma 4 E2B local backend via ollama config option (offline mode)
Gemma 4 E2B (via ollama) is the primary OCR+translation engine.
OpenRouter (Gemini 2.0 Flash) is the fallback, invoked only when Gemma cannot produce a translation.
This means:
-
scripts/run.shalways starts the ollama container (not only whenOPENROUTER_API_KEYis absent) -
OPENROUTER_API_KEYbecomes optional — only needed if you want the fallback to work - Both endpoints are configured simultaneously;
client.rsgains aDualOcrClientwrapper
There are two paths to the OpenRouter fallback:
Automatic — Gemma's response meets any of these conditions:
| Condition | Meaning |
|---|---|
Err(_) returned |
HTTP error, timeout, connection refused, or unparseable JSON |
Ok(vec![]) returned |
Gemma found no text in the image |
All results have english: None or english: Some("")
|
OCR succeeded but translation was skipped or refused |
A partial result (some items have english, some don't) does not trigger a full fallback — the items without translation are simply presented as-is. The fallback is a per-capture decision, not per-result.
Manual — Ctrl+Shift+Click — user explicitly forces the remote backend:
- Skips
DualOcrClientprimary path entirely; calls OpenRouter directly - Useful for comparing Gemma vs Gemini output, or when Gemma is slow/unavailable
- Requires
OPENROUTER_API_KEYset; displays an error message in the HUD if the key is absent - Detected in
main.rsinput handler (around line 438) by addingCONTROL_MASKto the existingSHIFT_MASK + BUTTON1_MASKcheck - Existing
Shift+Click(no Ctrl) always uses the primary (Gemma) path as before
New struct in lenzu/src/client.rs:
pub struct DualOcrClient {
primary: OcrClient, // Gemma via ollama
fallback: Option<OcrClient>, // OpenRouter — None if no API key configured
}
impl DualOcrClient {
pub fn call_api(&self, b64: &str) -> Result<Vec<TranslationResult>, Box<dyn std::error::Error>> {
match self.try_primary(b64) {
Ok(results) if self.needs_fallback(&results) => {
eprintln!("[OCR] primary gave no translations — trying fallback");
self.try_fallback(b64)
}
Ok(results) => Ok(results),
Err(e) => {
eprintln!("[OCR] primary failed ({e}) — trying fallback");
self.try_fallback(b64)
}
}
}
fn needs_fallback(&self, results: &[TranslationResult]) -> bool {
// Empty result set, or every english field is blank
results.is_empty()
|| results.iter().all(|r| {
r.english.as_deref().map(|s| s.trim().is_empty()).unwrap_or(true)
})
}
fn try_primary(&self, b64: &str) -> Result<Vec<TranslationResult>, Box<dyn std::error::Error>> {
self.primary.call_api(b64)
}
fn try_fallback(&self, b64: &str) -> Result<Vec<TranslationResult>, Box<dyn std::error::Error>> {
match &self.fallback {
Some(fb) => fb.call_api(b64),
None => Err("no translation from primary and no fallback configured".into()),
}
}
}main.rs constructs a DualOcrClient instead of OcrClient. The call site is unchanged — call_api(b64) is the same method name.
OcrClient::call_api currently always sends Authorization: Bearer <key>. Ollama's local endpoint does not use auth. Two approaches:
Option 1 — Empty key = no header (minimal change):
if !self.api_key.is_empty() {
req = req.header("Authorization", format!("Bearer {}", self.api_key));
}Option 2 — is_local_endpoint() helper:
fn is_local(&self) -> bool {
self.endpoint.starts_with("http://localhost") || self.endpoint.starts_with("http://127.")
}Option 1 is simpler. The primary OcrClient is constructed with an empty api_key; the fallback OcrClient uses OPENROUTER_API_KEY.
New fields added to AppConfig with #[serde(default)] (backward-compatible):
{
// Primary (Gemma via ollama) — already exists, defaults change:
"llm_api_endpoint": "http://localhost:11434/v1/chat/completions",
"llm_default_model": "gemma4:e2b",
// Fallback (OpenRouter) — new fields:
"fallback_llm_api_endpoint": "https://openrouter.ai/api/v1/chat/completions",
"fallback_llm_model": "google/gemini-2.0-flash-001",
// fallback API key is read from env var OPENROUTER_API_KEY, not stored in config
}AppConfig default changes:
-
llm_api_endpointdefault →http://localhost:11434/v1/chat/completions -
llm_default_modeldefault →gemma4:e2b - Add
fallback_llm_api_endpoint: String(default: OpenRouter URL) - Add
fallback_llm_model: String(default:google/gemini-2.0-flash-001)
The fallback API key is never stored in config — always read from the OPENROUTER_API_KEY environment variable at startup.
let cfg = AppConfig::load();
let prompt = cfg.resolved_prompt();
let api_key = std::env::var("OPENROUTER_API_KEY").unwrap_or_default();
let primary = OcrClient::new(
String::new(), // no auth for local ollama
cfg.llm_api_endpoint.clone(),
cfg.llm_default_model.clone(),
prompt.clone(),
);
let fallback = if api_key.is_empty() {
None
} else {
Some(OcrClient::new(
api_key,
cfg.fallback_llm_api_endpoint.clone(),
cfg.fallback_llm_model.clone(),
prompt,
))
};
let client = DualOcrClient { primary, fallback };The auto-detection logic changes: ollama starts always (it's the primary), not only when OPENROUTER_API_KEY is absent. OPENROUTER_API_KEY becomes a fallback enabler, not a backend selector:
# OLD: "no API key → use ollama"
# NEW: "always use ollama as primary; API key enables the fallback"
if command -v docker &>/dev/null; then
trap stop_ollama EXIT
start_ollama
else
echo "WARNING: Docker not found — ollama unavailable. Running on OpenRouter only."
fi
if [[ -z "${OPENROUTER_API_KEY:-}" ]]; then
echo "NOTE: OPENROUTER_API_KEY not set — OpenRouter fallback disabled."
fiWhen fallback fires, log to /dev/shm/api_debug.txt (already used for API response debugging):
[14:32:01] [FALLBACK] primary returned no translations — retrying with OpenRouter
- M7b scope expands: not just "ollama config option" but full dual-backend with automatic fallback
- Add to
planning.mdM7b description
OCR does not need colour — text contrast is carried entirely by luminance. Converting to grayscale immediately after capture is essentially free (sub-millisecond) and benefits all backends by removing irrelevant colour information that can confuse VLM attention heads. Two preprocessing steps are applied, but at different scopes:
- Grayscale — applied to all captures (both local Gemma and remote OpenRouter). Converting RGB → Luma8 reduces PNG size by roughly ⅓ and focuses the model on luminance contrast only.
-
Proportional downscale — applied only before fallback (OpenRouter) calls. The remote call is billed per token and vision tokens scale with image byte size. If either dimension exceeds
fallback_max_dimension, the image is resized (maintaining aspect ratio) before encoding. Fewer pixels = smaller PNG = fewer tokens.
Local inference (Gemma/ollama) receives a grayscale, full-resolution image. Remote inference (OpenRouter) receives a grayscale, downscaled image.
| Image | Path | Format | Approx PNG bytes | Approx tokens |
|---|---|---|---|---|
| 400×400 lens capture | (original) | RGB colour | ~60 KB | ~800–1,200 |
| 400×400 lens capture | Local (Gemma) | Grayscale, full res | ~20 KB | ~270–400 |
| 400×400 lens capture | Remote (OpenRouter) | Grayscale + scaled ≤800px | ~12 KB | ~160–240 |
Savings on the fallback call: 70–80% vs. the original full-colour send. Local call benefits from grayscale alone (~66% reduction) at zero token cost.
Three new public functions alongside the existing encode_to_base64:
use image::{DynamicImage, ImageFormat, imageops::FilterType};
/// Convert raw BGRA bytes to a DynamicImage (used at capture time before encoding).
pub fn raw_to_dynamic_image(raw: &[u8], w: u32, h: u32) -> DynamicImage {
let rgb = raw_to_rgb(raw);
let buf = image::ImageBuffer::<image::Rgb<u8>, _>::from_raw(w, h, rgb)
.expect("raw_to_dynamic_image: dimensions mismatch");
DynamicImage::ImageRgb8(buf)
}
/// Encode a DynamicImage as grayscale PNG → base64.
/// Applied to ALL captures (both local and remote) immediately after capture.
pub fn encode_as_grayscale(image: &DynamicImage) -> String {
let gray = image.grayscale();
let mut buf = std::io::Cursor::new(Vec::new());
gray.write_to(&mut buf, ImageFormat::Png).unwrap();
base64::engine::general_purpose::STANDARD.encode(buf.into_inner())
}
/// Encode a DynamicImage as grayscale + proportionally downscaled PNG → base64.
/// `max_dim`: longest edge limit in pixels; 0 = no limit.
/// Applied ONLY before remote (OpenRouter/fallback) calls to reduce token cost.
pub fn encode_for_fallback(image: &DynamicImage, max_dim: u32) -> String {
let gray = image.grayscale();
// Proportional downscale if over the limit
let scaled = if max_dim > 0 {
let (w, h) = gray.dimensions();
if w > max_dim || h > max_dim {
gray.resize(max_dim, max_dim, FilterType::Lanczos3)
} else {
gray
}
} else {
gray
};
let mut buf = std::io::Cursor::new(Vec::new());
scaled.write_to(&mut buf, ImageFormat::Png).unwrap();
base64::engine::general_purpose::STANDARD.encode(buf.into_inner())
}DynamicImage::grayscale() and resize() are in the core image crate — no new dependencies. The png feature already enabled in Cargo.toml covers encoding.
To apply different encoding for primary vs fallback, DualOcrClient::call_api accepts a &DynamicImage instead of a pre-encoded &str. It encodes internally, applying grayscale to both paths and downscale only to the fallback path:
impl DualOcrClient {
/// `image`: the captured lens region as a DynamicImage (raw RGB, full resolution).
pub fn call_api(&self, image: &DynamicImage) -> Result<Vec<TranslationResult>, Box<dyn std::error::Error>> {
// Primary: grayscale, full resolution — colour info not needed for OCR
let primary_b64 = encode_as_grayscale(image);
match self.try_primary(&primary_b64) {
Ok(results) if !self.needs_fallback(&results) => Ok(results),
primary_result => {
if let Err(ref e) = primary_result {
eprintln!("[OCR] primary failed ({e}) — trying fallback");
} else {
eprintln!("[OCR] primary gave no translations — trying fallback");
}
// Fallback: grayscale + downscale to cut remote token cost
let fallback_b64 = encode_for_fallback(image, self.fallback_preprocess.max_dimension);
self.try_fallback(&fallback_b64)
}
}
}
}The main.rs background thread call site becomes:
// Before: client.call_api(&b64_string)
// After:
let dyn_image = raw_to_dynamic_image(&raw, w, h);
let results = client.call_api(&dyn_image)?;{
// Preprocessing applied to ALL captures (local + remote):
// grayscale conversion is always-on and has no config knob.
// Additional preprocessing applied ONLY before fallback (OpenRouter) calls:
"fallback_max_dimension": 800 // default: 800; set 0 to disable downscaling
}Added to AppConfig with #[serde(default)]. Default of 800px means the 400×400 lens capture is untouched (already under limit), but large crops from pre-detection (Phase 4 §4) are still bounded.
Why remove
fallback_preprocess_grayscaleas a config knob? Grayscale now applies to all captures, so it is no longer specific to the fallback path. Keeping a toggle would imply it could be disabled for primary too, which is not the intent. If a future use case needs per-path control, this can be re-introduced then.
#[derive(Debug, Clone)]
pub struct FallbackPreprocess {
pub max_dimension: u32, // 0 = no limit; applies only to fallback (remote) calls
}
impl Default for FallbackPreprocess {
fn default() -> Self {
Self { max_dimension: 800 }
}
}Grayscale is always applied at capture time (both paths) and is not stored here. FallbackPreprocess tracks only the downscale limit, which is fallback-specific. Stored in DualOcrClient; read from AppConfig at construction time in main.rs.
When pre-detection is active, multiple small crops are sent individually. Each crop goes through the same dual-backend path — grayscale is applied to all crops before Gemma (local) and before OpenRouter (remote); downscale is applied only for the remote path. Since crops are already small (typically 60–200px wide), the max_dimension cap rarely triggers, but grayscale still cuts their size by ~⅔ in both paths.
Save the preprocessed fallback image to /dev/shm/debug_lens_fallback.png alongside the existing /dev/shm/debug_lens.png, so the two can be compared side-by-side during development.
The Electron HUD migration (Phase 3) uncovered cascading issues — ghosting artefacts, UDP message timing, IPC race conditions — that were caught late through manual QA. Unit tests on the boundary contracts (message format, port negotiation, lifecycle ordering) would have surfaced these earlier. Phase 4 introduces three new code boundaries (DualOcrClient, encode_as_grayscale/encode_for_fallback, TextDetector) that each need tests before integration.
| Module | New code | Test location |
|---|---|---|
utils.rs |
raw_to_dynamic_image, encode_as_grayscale, encode_for_fallback
|
utils.rs #[cfg(test)] |
client.rs |
DualOcrClient, needs_fallback, auth header logic |
client.rs #[cfg(test)] |
config.rs |
new fallback fields + defaults, backward compat | config.rs #[cfg(test)] |
ocr/text_detection.rs |
OcrRect::scale_to, OcrRect::padded, remap_coords
|
text_detection.rs #[cfg(test)] |
tests/integration_test.rs |
dual-backend end-to-end flows | tests/integration_test.rs |
#[cfg(test)]
mod tests {
use super::*;
use image::DynamicImage;
// ── raw_to_dynamic_image ─────────────────────────────────────────────────
#[test]
fn test_raw_to_dynamic_image_dimensions() {
// 2x2 BGRA image
let raw = vec![0u8; 4 * 2 * 2];
let img = raw_to_dynamic_image(&raw, 2, 2);
assert_eq!(img.width(), 2);
assert_eq!(img.height(), 2);
}
#[test]
fn test_raw_to_dynamic_image_is_rgb() {
let raw = vec![10, 20, 30, 255]; // 1 BGRA pixel: B=10 G=20 R=30
let img = raw_to_dynamic_image(&raw, 1, 1);
// Should be RGB8 (not RGBA, not Luma)
assert!(matches!(img, DynamicImage::ImageRgb8(_)));
let pixel = img.as_rgb8().unwrap().get_pixel(0, 0);
assert_eq!(pixel[0], 30); // R
assert_eq!(pixel[1], 20); // G
assert_eq!(pixel[2], 10); // B
}
// ── encode_as_grayscale — used for ALL captures (local + remote primary) ──
#[test]
fn test_encode_as_grayscale_produces_grayscale_png() {
let mut img = image::RgbImage::new(4, 4);
for p in img.pixels_mut() { *p = image::Rgb([0, 128, 255]); } // solid blue
let dyn_img = DynamicImage::ImageRgb8(img);
let b64 = encode_as_grayscale(&dyn_img);
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert!(
matches!(decoded, DynamicImage::ImageLuma8(_) | DynamicImage::ImageLumaA8(_)),
"encode_as_grayscale must produce a grayscale PNG"
);
}
#[test]
fn test_encode_as_grayscale_preserves_dimensions() {
let img = DynamicImage::new_rgb8(100, 80);
let b64 = encode_as_grayscale(&img);
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert_eq!(decoded.width(), 100);
assert_eq!(decoded.height(), 80);
}
#[test]
fn test_encode_as_grayscale_smaller_than_colour() {
let img = DynamicImage::new_rgb8(400, 400);
let colour_b64 = encode_to_base64(&img.to_rgb8().into_raw(), 400, 400);
let gray_b64 = encode_as_grayscale(&img);
assert!(
gray_b64.len() < colour_b64.len(),
"grayscale b64 ({}) should be smaller than colour b64 ({})",
gray_b64.len(), colour_b64.len()
);
}
// ── encode_for_fallback — grayscale + downscale (remote only) ────────────
#[test]
fn test_encode_for_fallback_produces_grayscale_png() {
// Create a small coloured image
let mut img = image::RgbImage::new(4, 4);
for p in img.pixels_mut() { *p = image::Rgb([255, 0, 0]); } // solid red
let dyn_img = DynamicImage::ImageRgb8(img);
let b64 = encode_for_fallback(&dyn_img, 0); // 0 = no dimension limit
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
// Decode the PNG and confirm it is Luma (grayscale)
let decoded = image::load_from_memory(&bytes).unwrap();
assert!(
matches!(decoded, DynamicImage::ImageLuma8(_) | DynamicImage::ImageLumaA8(_)),
"encode_for_fallback must produce a grayscale PNG"
);
}
#[test]
fn test_encode_for_fallback_is_smaller_than_primary() {
// 400x400 image — fallback (grayscale+downscale) must be smaller than primary (grayscale only)
let img = DynamicImage::new_rgb8(400, 400);
let primary_b64 = encode_as_grayscale(&img);
let fallback_b64 = encode_for_fallback(&img, 300); // force downscale
assert!(
fallback_b64.len() < primary_b64.len(),
"fallback b64 ({}) should be smaller than primary b64 ({}) after downscale",
fallback_b64.len(), primary_b64.len()
);
}
// ── encode_for_fallback — downscaling ────────────────────────────────────
#[test]
fn test_encode_for_fallback_no_resize_when_under_limit() {
let img = DynamicImage::new_rgb8(100, 80);
let b64 = encode_for_fallback(&img, 200); // limit 200, image is 100x80
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert_eq!(decoded.width(), 100); // unchanged
assert_eq!(decoded.height(), 80);
}
#[test]
fn test_encode_for_fallback_downscales_wide_image() {
let img = DynamicImage::new_rgb8(1000, 400); // wide
let b64 = encode_for_fallback(&img, 800);
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert!(decoded.width() <= 800, "width must be clamped to max_dim");
assert!(decoded.height() <= 800);
// Aspect ratio preserved: 1000x400 → 800x320
assert_eq!(decoded.width(), 800);
assert_eq!(decoded.height(), 320);
}
#[test]
fn test_encode_for_fallback_downscales_tall_image() {
let img = DynamicImage::new_rgb8(300, 900); // tall
let b64 = encode_for_fallback(&img, 800);
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert!(decoded.height() <= 800);
assert_eq!(decoded.height(), 800);
assert_eq!(decoded.width(), 266); // 300 * 800/900 ≈ 266
}
#[test]
fn test_encode_for_fallback_zero_max_dim_means_no_resize() {
let img = DynamicImage::new_rgb8(2000, 2000);
let b64 = encode_for_fallback(&img, 0); // 0 = no limit
let bytes = base64::engine::general_purpose::STANDARD.decode(&b64).unwrap();
let decoded = image::load_from_memory(&bytes).unwrap();
assert_eq!(decoded.width(), 2000);
}
}These are unit tests; HTTP calls are replaced by mock OcrClient stand-ins. Use wiremock for the integration variants, but for unit tests stub OcrClient via a trait or by constructing clients that point at a mock server spun up within the test.
#[cfg(test)]
mod dual_client_tests {
use super::*;
// ── needs_fallback ───────────────────────────────────────────────────────
#[test]
fn test_needs_fallback_empty_vec() {
let c = make_dual_client();
assert!(c.needs_fallback(&[]));
}
#[test]
fn test_needs_fallback_all_null_english() {
let results = vec![
TranslationResult { original: "hello".into(), english: None, ..Default::default() },
TranslationResult { original: "world".into(), english: Some("".into()), ..Default::default() },
];
let c = make_dual_client();
assert!(c.needs_fallback(&results));
}
#[test]
fn test_needs_fallback_some_english_present() {
let results = vec![
TranslationResult { original: "a".into(), english: Some("A".into()), ..Default::default() },
TranslationResult { original: "b".into(), english: None, ..Default::default() },
];
let c = make_dual_client();
// At least one result has english → partial success, no fallback
assert!(!c.needs_fallback(&results));
}
#[test]
fn test_needs_fallback_whitespace_only_english() {
// " " should count as empty
let results = vec![
TranslationResult { original: "x".into(), english: Some(" ".into()), ..Default::default() },
];
assert!(make_dual_client().needs_fallback(&results));
}
// ── auth header behaviour ────────────────────────────────────────────────
#[test]
fn test_ocr_client_empty_key_omits_auth_header() {
// OcrClient with empty api_key must NOT add Authorization header.
// Verified by inspecting generated_payload / request headers.
let c = OcrClient::new(String::new(), "http://localhost:11434/v1".into(), "gemma4:e2b".into(), "p".into());
// Build the request without sending; inspect header presence.
// (Use reqwest::blocking::Client::request() + RequestBuilder::build() to inspect)
let req = c.build_request("dGVzdA==").build().unwrap();
assert!(
req.headers().get("authorization").is_none(),
"empty api_key must not produce an Authorization header"
);
}
#[test]
fn test_ocr_client_non_empty_key_includes_auth_header() {
let c = OcrClient::new("sk-abc".into(), "https://openrouter.ai/v1".into(), "gemini".into(), "p".into());
let req = c.build_request("dGVzdA==").build().unwrap();
let auth = req.headers().get("authorization").unwrap().to_str().unwrap();
assert_eq!(auth, "Bearer sk-abc");
}
}Note:
build_requestis a new private helper that returns aRequestBuilder(extracted fromcall_api) so headers can be inspected in tests without making a real HTTP call.
#[cfg(test)]
mod tests {
// ... existing tests ...
#[test]
fn test_fallback_endpoint_default_is_openrouter() {
let cfg = AppConfig::default();
assert!(cfg.fallback_llm_api_endpoint.contains("openrouter.ai"));
}
#[test]
fn test_fallback_model_default_is_gemini_flash() {
let cfg = AppConfig::default();
assert!(cfg.fallback_llm_model.contains("gemini"));
}
#[test]
fn test_primary_endpoint_default_is_localhost_ollama() {
let cfg = AppConfig::default();
assert!(cfg.llm_api_endpoint.contains("localhost:11434"));
}
#[test]
fn test_fallback_max_dimension_default_is_800() {
let cfg = AppConfig::default();
assert_eq!(cfg.fallback_max_dimension, 800);
}
#[test]
fn test_old_config_without_fallback_fields_loads_with_defaults() {
// Simulate a pre-Phase-4 config file that has no fallback_* fields.
let old_json = r#"{
"lens_size": 400,
"ui_panel_height": 130,
"font_size": 13.0,
"hud_color_hex": "#00FFCC",
"show_romaji": true,
"show_furigana": true,
"overlay_enabled": true,
"overlay_udp_port": 7331,
"llm_api_endpoint": "https://openrouter.ai/api/v1/chat/completions",
"llm_default_model": "google/gemini-2.0-flash-001",
"translate_src": "jpn",
"translate_dest": "eng",
"translate_extra_prompt": "",
"overlay_render_mode": "furigana"
}"#;
let cfg: AppConfig = serde_json::from_str(old_json)
.expect("old config without fallback fields must still deserialize");
// New fields should have their defaults
assert_eq!(cfg.fallback_max_dimension, 800);
assert!(!cfg.fallback_llm_api_endpoint.is_empty());
}
}#[cfg(test)]
mod tests {
use super::*;
// ── scale_to ─────────────────────────────────────────────────────────────
#[test]
fn test_scale_to_identity() {
let r = OcrRect { x1: 100, y1: 50, x2: 200, y2: 150, confidence: 1.0 };
let scaled = r.scale_to(640, 640); // same dimensions as model space
assert_eq!(scaled.x1, 100);
assert_eq!(scaled.y1, 50);
}
#[test]
fn test_scale_to_half_size_original() {
// Original image is 320x320 (half of 640 model space)
let r = OcrRect { x1: 320, y1: 160, x2: 480, y2: 320, confidence: 0.9 };
let scaled = r.scale_to(320, 320);
assert_eq!(scaled.x1, 160); // 320 * 320/640
assert_eq!(scaled.y1, 80);
assert_eq!(scaled.x2, 240);
assert_eq!(scaled.y2, 160);
}
#[test]
fn test_scale_to_clamps_to_image_bounds() {
// A rect at the very edge of model space
let r = OcrRect { x1: 600, y1: 600, x2: 640, y2: 640, confidence: 0.5 };
let scaled = r.scale_to(400, 400); // smaller than model space
assert!(scaled.x2 <= 400);
assert!(scaled.y2 <= 400);
}
// ── padded ───────────────────────────────────────────────────────────────
#[test]
fn test_padded_expands_rect() {
let r = OcrRect { x1: 10, y1: 10, x2: 50, y2: 50, confidence: 1.0 };
let padded = r.padded(5, 400, 400);
assert_eq!(padded.x1, 5);
assert_eq!(padded.y1, 5);
assert_eq!(padded.x2, 55);
assert_eq!(padded.y2, 55);
}
#[test]
fn test_padded_clamps_at_zero() {
// Rect near top-left corner
let r = OcrRect { x1: 3, y1: 2, x2: 50, y2: 50, confidence: 1.0 };
let padded = r.padded(10, 400, 400);
assert_eq!(padded.x1, 0); // saturating_sub clamps at 0
assert_eq!(padded.y1, 0);
}
#[test]
fn test_padded_clamps_at_image_edge() {
// Rect near bottom-right corner
let r = OcrRect { x1: 350, y1: 350, x2: 395, y2: 395, confidence: 1.0 };
let padded = r.padded(10, 400, 400);
assert_eq!(padded.x2, 400); // .min(img_w)
assert_eq!(padded.y2, 400);
}
// ── remap_coords ─────────────────────────────────────────────────────────
#[test]
fn test_remap_coords_adds_offset() {
let mut result = TranslationResult {
top_xy: Some("10,20".into()),
bot_xy: Some("50,80".into()),
..Default::default()
};
remap_coords(&mut result, 100, 200); // crop origin was (100,200)
assert_eq!(result.top_xy.as_deref(), Some("110,220"));
assert_eq!(result.bot_xy.as_deref(), Some("150,280"));
}
#[test]
fn test_remap_coords_handles_missing_coords() {
let mut result = TranslationResult { top_xy: None, bot_xy: None, ..Default::default() };
remap_coords(&mut result, 50, 50); // must not panic
assert!(result.top_xy.is_none());
}
#[test]
fn test_remap_coords_handles_array_format() {
// Coords coerced from [x,y] array still remap correctly
let mut result = TranslationResult {
top_xy: Some("[79,48]".into()),
bot_xy: Some("[167,181]".into()),
..Default::default()
};
remap_coords(&mut result, 20, 30);
assert_eq!(result.top_xy.as_deref(), Some("99,78"));
assert_eq!(result.bot_xy.as_deref(), Some("187,211"));
}
}These tests use wiremock to stub both the ollama and OpenRouter endpoints.
// ── helper ───────────────────────────────────────────────────────────────────
fn make_small_image() -> image::DynamicImage {
image::DynamicImage::new_rgb8(4, 4)
}
fn gemma_response_with_translation() -> serde_json::Value {
json!({
"choices": [{ "message": { "content":
r#"[{"original":"こんにちは","english":"Hello","furigana":"今日[こんにち]は","romaji":"Konnichiwa","top_xy":"0,0","bot_xy":"10,10"}]"#
}}]
})
}
fn gemma_response_no_translation() -> serde_json::Value {
json!({
"choices": [{ "message": { "content":
r#"[{"original":"こんにちは","english":null}]"#
}}]
})
}
fn openrouter_response() -> serde_json::Value {
json!({
"choices": [{ "message": { "content":
r#"[{"original":"こんにちは","english":"Hello (fallback)","top_xy":"0,0","bot_xy":"10,10"}]"#
}}]
})
}
// ── tests ────────────────────────────────────────────────────────────────────
#[tokio::test]
async fn test_dual_client_uses_primary_when_it_succeeds() {
let primary_server = MockServer::start().await;
let fallback_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(gemma_response_with_translation())
).mount(&primary_server).await;
// fallback must NOT be called
Mock::given(method("POST")).respond_with(ResponseTemplate::new(200))
.expect(0)
.mount(&fallback_server).await;
let client = DualOcrClient::new_for_test(&primary_server.uri(), &fallback_server.uri(), "sk-test");
let results = client.call_api(&make_small_image()).unwrap();
assert_eq!(results[0].english.as_deref(), Some("Hello"));
}
#[tokio::test]
async fn test_dual_client_falls_back_when_primary_returns_no_translation() {
let primary_server = MockServer::start().await;
let fallback_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(gemma_response_no_translation())
).mount(&primary_server).await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(openrouter_response())
).mount(&fallback_server).await;
let client = DualOcrClient::new_for_test(&primary_server.uri(), &fallback_server.uri(), "sk-test");
let results = client.call_api(&make_small_image()).unwrap();
assert_eq!(results[0].english.as_deref(), Some("Hello (fallback)"));
}
#[tokio::test]
async fn test_dual_client_falls_back_when_primary_errors() {
let primary_server = MockServer::start().await;
let fallback_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(ResponseTemplate::new(503))
.mount(&primary_server).await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(openrouter_response())
).mount(&fallback_server).await;
let client = DualOcrClient::new_for_test(&primary_server.uri(), &fallback_server.uri(), "sk-test");
let results = client.call_api(&make_small_image()).unwrap();
assert_eq!(results[0].english.as_deref(), Some("Hello (fallback)"));
}
#[tokio::test]
async fn test_dual_client_errors_when_both_fail() {
let primary_server = MockServer::start().await;
let fallback_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(ResponseTemplate::new(503))
.mount(&primary_server).await;
Mock::given(method("POST")).respond_with(ResponseTemplate::new(502))
.mount(&fallback_server).await;
let client = DualOcrClient::new_for_test(&primary_server.uri(), &fallback_server.uri(), "sk-test");
assert!(client.call_api(&make_small_image()).is_err());
}
#[tokio::test]
async fn test_dual_client_no_fallback_configured_returns_error_on_primary_failure() {
let primary_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(ResponseTemplate::new(503))
.mount(&primary_server).await;
let client = DualOcrClient::new_for_test_no_fallback(&primary_server.uri());
assert!(client.call_api(&make_small_image()).is_err());
}
#[tokio::test]
async fn test_fallback_image_sent_is_smaller_than_primary() {
// Capture the request bodies from both servers and compare sizes
let primary_server = MockServer::start().await;
let fallback_server = MockServer::start().await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(gemma_response_no_translation())
).mount(&primary_server).await;
Mock::given(method("POST")).respond_with(
ResponseTemplate::new(200).set_body_json(openrouter_response())
).mount(&fallback_server).await;
// Use a larger image so the size difference is visible.
// primary gets: grayscale, full-resolution (400×400)
// fallback gets: grayscale + downscaled to max_dimension (default 800, so 400×400 is
// under the limit here — use fallback_max_dimension=300 via new_for_test to force resize)
let img = image::DynamicImage::new_rgb8(400, 400);
let client = DualOcrClient::new_for_test_with_max_dim(
&primary_server.uri(), &fallback_server.uri(), "sk", 300
);
let _ = client.call_api(&img);
let primary_reqs = primary_server.received_requests().await.unwrap();
let fallback_reqs = fallback_server.received_requests().await.unwrap();
assert_eq!(primary_reqs.len(), 1);
assert_eq!(fallback_reqs.len(), 1);
let primary_body_len = primary_reqs[0].body.len();
let fallback_body_len = fallback_reqs[0].body.len();
assert!(
fallback_body_len < primary_body_len,
"fallback payload ({fallback_body_len}B) must be smaller than primary ({primary_body_len}B) \
— both are grayscale; fallback is additionally downscaled"
);
}
DualOcrClient::new_for_testandnew_for_test_no_fallbackare#[cfg(test)]-only constructors that accept explicit endpoint URLs, bypassing config loading. This avoids test coupling to the filesystem.
| Risk | Caught by |
|---|---|
| Fallback fires unnecessarily (primary was fine) |
test_dual_client_uses_primary_when_it_succeeds + .expect(0) on fallback mock |
| Fallback silently swallowed (never fires on failure) | test_dual_client_falls_back_when_primary_errors |
| Partial translations incorrectly trigger fallback | test_needs_fallback_some_english_present |
| Whitespace-only translations treated as success | test_needs_fallback_whitespace_only_english |
| Old config files break on upgrade | test_old_config_without_fallback_fields_loads_with_defaults |
| Coord remapping wrong after crop offset |
test_remap_coords_adds_offset + array format variant |
| Primary path sends colour instead of grayscale | test_encode_as_grayscale_produces_grayscale_png |
| Grayscale dimensions change unexpectedly | test_encode_as_grayscale_preserves_dimensions |
| Fallback sends larger image than primary |
test_fallback_image_sent_is_smaller_than_primary (integration) + test_encode_for_fallback_is_smaller_than_primary (unit) |
| Fallback grayscale conversion regresses to colour | test_encode_for_fallback_produces_grayscale_png |
| Aspect ratio broken by downscale |
test_encode_for_fallback_downscales_wide_image + tall variant |
| Auth header sent to ollama (local) | test_ocr_client_empty_key_omits_auth_header |
The YOLO pre-detection path (§2–§4) requires a fine-tuned model for manga speech bubbles — the existing COCO-trained models have no text or bubble class. Fine-tuning requires training data (Manga109 balloon annotations) and a separate model file to ship with the application. Until that work is done, there is a simpler interim path that uses Gemma itself as the text-region detector, without YOLO.
The pipeline becomes a two-request sequence per capture, both served by DualOcrClient:
Shift+Click
→ capture lens → DynamicImage
Pass 1 — Region detection (single request):
→ prompt: "Find all text regions. Return a JSON array of {top_xy, bot_xy} only.
Do not read or transcribe the text. Return ONLY the JSON array."
→ model returns: [{top_xy:"x,y", bot_xy:"x,y"}, ...]
Pass 2 — OCR per crop (one request per region, parallelizable):
→ for each rect from pass 1:
crop DynamicImage to rect (+padding)
encode_as_grayscale(crop)
prompt: "Read the Japanese text in this image. Return JSON:
{original, furigana, english, romaji}"
model returns: one TranslationResult
→ remap top_xy / bot_xy from crop-local → lens-local coords (same as §6)
→ collect all TranslationResults
| Step | Current | Gemma 2-pass |
|---|---|---|
| Images sent | 1 full image | 1 full image (pass 1) + N small crops (pass 2) |
| Output tokens (pass 1) | — | ~10–20 tokens (just coordinates) |
| Output tokens (pass 2) | All text + coords in one go | ~20–40 tokens per crop |
| Vision tokens (pass 2) | Full image × 1 | Crop (~100×50px) × N |
| VRAM pressure | Full image in KV cache | Tiny crops; short context per call |
| Parallelism | None | Pass 2 calls are independent → tokio::spawn
|
The key saving is that each pass-2 crop is a tiny image with a short output — the vision encoder processes far fewer pixels, and the model produces fewer tokens. For a capture with 3 text regions, 3 parallel crop requests will typically complete faster than 1 request asking the model to read and locate everything at once.
| YOLO/ONNX | Gemma prompt | |
|---|---|---|
| Latency (pass 1) | ~30–80 ms (local CPU) | ~500ms–2s (LLM inference) |
| Model accuracy on manga | Low (COCO classes) → needs fine-tune | Reasonable (can reason about visual layout) |
| Required setup | ONNX model file on disk | Nothing new — already running |
| Network required | No | No (ollama is local) |
| Bounding box precision | Tight (pixel-level) | Approximate (model guesses, not measures) |
| When to prefer | After fine-tuned model is available | Now, as an interim path |
Interim strategy: ship Gemma-based detection for the initial release. Add YOLO when a manga-specific model is ready to swap in. The pass-2 crop-and-OCR logic is identical either way — only the source of Vec<OcrRect> changes.
Find all regions in this image that contain text.
Return a JSON array of objects, one per text region.
Each object must have:
top_xy — upper-left corner of the region as string "x,y"
bot_xy — lower-right corner of the region as string "x,y"
Do NOT read or transcribe the text. Do NOT include any other fields.
Return ONLY the JSON array, no markdown fences.
This is a simpler task than OCR — the model only needs to draw rectangles, not recognize characters. The shorter output reduces inference time.
{
"detection_pass": "gemma", // "gemma" | "yolo" | "none"
// "gemma" = two-request pipeline (this section)
// "yolo" = ONNX pre-detection (§4)
// "none" = single full-image OCR (current behaviour)
}"gemma" becomes the default once the two-pass plumbing is in place. "yolo" replaces it when a fine-tuned model is available. "none" is the fallback for minimal-install scenarios.
Pass-2 requests are independent and can be dispatched concurrently. In main.rs the background thread would use tokio::task::spawn or futures::future::join_all:
let crop_futures: Vec<_> = rects.iter().map(|rect| {
let r = rect.padded(8, w, h);
let crop = dyn_image.crop_imm(r.x1, r.y1, r.width(), r.height());
let client = client.clone(); // Arc<DualOcrClient>
tokio::spawn(async move {
let b64 = encode_as_grayscale(&crop);
let mut results = client.call_api_b64(&b64)?;
remap_coords(&mut results, r.x1 as i64, r.y1 as i64);
Ok::<_, Box<dyn std::error::Error + Send + Sync>>(results)
})
}).collect();
let all_results: Vec<TranslationResult> = futures::future::join_all(crop_futures)
.await
.into_iter()
.flatten()
.flatten()
.collect();Note: ollama processes requests sequentially (one model, one GPU). Parallel requests will queue internally. The concurrency benefit is real only if a remote backend is used for pass 2 (OpenRouter handles parallel requests). With a local ollama backend, serial dispatch is equivalent.
scripts/test-ocr.sh was updated to test both the local-ollama and remote-OpenRouter backends in a single run, with separate timing for each.
# Test both backends (default):
./scripts/test-ocr.sh
# Test local only:
./scripts/test-ocr.sh --skip-remote
# Test remote only:
./scripts/test-ocr.sh --skip-ollama --remote-key "$OPENROUTER_API_KEY"
# Custom models:
./scripts/test-ocr.sh --model gemma4:e4b --remote-model google/gemini-2.0-flash-001If OPENROUTER_API_KEY is not set and --remote-key is not passed, the remote backend is skipped with a notice (not a failure) — local-only setups are not penalized.
The expected JSON (assets/Unit-test-sample-texts.json) stores bounding-box coordinates for the original 2816×1536 source image. The model receives a scaled-down copy (default: 640px longest edge) — a scale factor of ~4.4×. Even at the same scale, VLMs do not reliably reproduce exact pixel coordinates; they approximate spatial layout. Coordinates are therefore printed as [WARN] lines and do not contribute to the pass/fail count.
The only checked assertions are:
- HTTP 200 response
- Response parses as a JSON array
- Expected text substrings are present in the results
Two ollama-specific options reduce VRAM pressure on cards with limited headroom:
| Parameter | Previous | Current | Reason |
|---|---|---|---|
max_dim (image longest edge) |
896 px | 640 px | Smaller image = smaller vision encoder activation = less VRAM |
options.num_ctx |
(unset, ~4096) | 2048 | Smaller KV cache = more VRAM free for computation |
These are passed in the options block of the ollama API request. OpenRouter ignores unknown options fields, so the same request JSON is safe to send to both backends.
| Configuration | Inference time |
|---|---|
| 896 px, default num_ctx, CPU (linuxbrew) | ~4 minutes |
| 896 px, default num_ctx, GPU (official binary) | ~28 s |
| 640 px, num_ctx=2048, GPU | target < 10 s (to be measured) |
The Quadro M4000 has ~389 MB VRAM remaining after the model loads (7.9 GB model in 8 GB VRAM). Vision encoder activations for a 640px image consume less of that headroom than a 896px image.
Once the Gemma detection pass (§15) is implemented, test-ocr.sh should gain a --two-pass flag that:
- Sends pass-1 (bounding-box only) request and prints detected rects
- For each rect, sends a crop and verifies the expected text is present in that crop's response
- Reports timing for each pass separately
CodeMonkeyNinja/lenzu · MIT
- technical-design
- technical-design.lens-window
- GTK-Migrations
- technical-design.OCR
- technical-design.manga-ocr
- technical-design.sarashina
- technical-design.phase4-predetect
- technical-design.cancel-inflight
{ // ... existing fields ... // Path to the text-detection ONNX model (relative to CWD or absolute). // Omit or set to null to disable pre-detection and adaptive capture entirely. // Default: null "text_detection_model": "assets/stabrise-text_detection_dbnet_ml_v02_model.onnx", // DBNet probability-map binarization threshold (0.0–1.0). // Pixels with probability > threshold are marked as text. // Lower = more sensitive (more regions detected, more noise); higher = stricter. // Default: 0.3 "text_detection_threshold": 0.3, // ── Adaptive capture (virtual lens stretch) ─────────────────────────────── // Multiplier applied to lens_size to compute the oversample capture area. // The oversample is always taken at max(lens_size * factor, 640) to ensure // DBNet gets at least native-resolution input. Higher values catch larger // text at the cost of more X11 bandwidth per click. Default: 2.0 "text_detection_oversample_factor": 2.0, // Hard ceiling on the oversample dimension (width or height) in pixels. // Text larger than this cannot be auto-detected; use manual selection. // Default: 1600 "text_detection_max_capture_size": 1600, // Padding (px) added around the detected text union on all four sides // before sending the crop to OCR. Provides context for the LLM. Default: 16. "text_detection_crop_padding": 16, // When true: saves an annotated detection image to /dev/shm/lenzu/debug_detection.png // after every Shift+Click or Ctrl+Shift+Click, and draws green box outlines on the // lens window for Shift+Click detections. Default: false. "text_detection_debug": false }