Mayo commited on
feat: better text rendering
Browse files
koharu-app/src/pipeline/engines/bubble_segmentation.rs
CHANGED
|
@@ -1,25 +1,13 @@
|
|
| 1 |
-
//! Speech-bubble segmentation. Produces a `Mask { Bubble }` layer where
|
| 2 |
-
//!
|
| 3 |
-
//! is `0`. The renderer consumes this mask
|
| 4 |
-
//!
|
| 5 |
-
//! available space instead of being shrunk to fit the detector's
|
| 6 |
-
//! (Japanese-shaped) source bbox.
|
| 7 |
-
//!
|
| 8 |
-
//! We build the mask from the model's **detection bboxes** (one per
|
| 9 |
-
//! detected bubble), not from its per-pixel probability map. The model's
|
| 10 |
-
//! bbox head is consistently accurate while its mask head under-segments
|
| 11 |
-
//! bubble interiors — tight, text-shaped blobs that don't cover the full
|
| 12 |
-
//! balloon. A text rect computed from the under-segmented mask would land
|
| 13 |
-
//! wherever that small blob is (often off-centre in the actual bubble),
|
| 14 |
-
//! which is exactly the "text outside the bubble" artefact. Bbox-filled
|
| 15 |
-
//! rectangles trade a tiny bit of accuracy in the balloon's curved corners
|
| 16 |
-
//! (handled by the renderer's inset) for guaranteed full coverage.
|
| 17 |
|
| 18 |
use anyhow::Result;
|
| 19 |
use async_trait::async_trait;
|
| 20 |
use image::{DynamicImage, GrayImage, Luma};
|
| 21 |
use koharu_core::{MaskRole, Op};
|
| 22 |
-
use koharu_ml::speech_bubble_segmentation::SpeechBubbleSegmentation;
|
| 23 |
|
| 24 |
use crate::pipeline::artifacts::Artifact;
|
| 25 |
use crate::pipeline::engine::{Engine, EngineCtx, EngineInfo};
|
|
@@ -33,36 +21,9 @@ impl Engine for Model {
|
|
| 33 |
let image = load_source_image(ctx.scene, ctx.page, ctx.blobs)?;
|
| 34 |
let result = self.0.inference(&image)?;
|
| 35 |
|
| 36 |
-
let w = result.image_width;
|
| 37 |
-
let h = result.image_height;
|
| 38 |
-
let mut mask = GrayImage::from_pixel(w, h, Luma([0u8]));
|
| 39 |
-
|
| 40 |
-
// Each detected bubble gets a unique non-zero grayscale ID in the
|
| 41 |
-
// mask. The renderer reads the ID under a text seed to recover
|
| 42 |
-
// exactly which bubble bbox that seed belongs to — no flood fill,
|
| 43 |
-
// no CC merging, no partition heuristics. Overlapping bboxes stay
|
| 44 |
-
// separable because smaller bubbles overwrite larger ones (painted
|
| 45 |
-
// last, after sorting by descending area), so a text seed inside
|
| 46 |
-
// a nested or embedded small bubble reads the small bubble's ID.
|
| 47 |
-
//
|
| 48 |
-
// Cap at 255 IDs; typical manga pages have well under 20 bubbles.
|
| 49 |
let mut regions: Vec<_> = result.regions.iter().collect();
|
| 50 |
regions.sort_by_key(|region| std::cmp::Reverse(region.area));
|
| 51 |
-
|
| 52 |
-
let id = (i + 1) as u8;
|
| 53 |
-
let x0 = region.bbox[0].floor().max(0.0) as u32;
|
| 54 |
-
let y0 = region.bbox[1].floor().max(0.0) as u32;
|
| 55 |
-
let x1 = (region.bbox[2].ceil().max(0.0) as u32).min(w);
|
| 56 |
-
let y1 = (region.bbox[3].ceil().max(0.0) as u32).min(h);
|
| 57 |
-
if x1 <= x0 || y1 <= y0 {
|
| 58 |
-
continue;
|
| 59 |
-
}
|
| 60 |
-
for y in y0..y1 {
|
| 61 |
-
for x in x0..x1 {
|
| 62 |
-
mask.put_pixel(x, y, Luma([id]));
|
| 63 |
-
}
|
| 64 |
-
}
|
| 65 |
-
}
|
| 66 |
|
| 67 |
let blob = ctx.blobs.put_webp(&DynamicImage::ImageLuma8(mask))?;
|
| 68 |
Ok(vec![upsert_mask_blob(
|
|
@@ -74,6 +35,35 @@ impl Engine for Model {
|
|
| 74 |
}
|
| 75 |
}
|
| 76 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 77 |
inventory::submit! {
|
| 78 |
EngineInfo {
|
| 79 |
id: "speech-bubble-segmentation",
|
|
@@ -86,3 +76,35 @@ inventory::submit! {
|
|
| 86 |
}),
|
| 87 |
}
|
| 88 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
//! Speech-bubble segmentation. Produces a `Mask { Bubble }` layer where each
|
| 2 |
+
//! detected balloon contour is encoded as a distinct non-zero grayscale ID and
|
| 3 |
+
//! everything else is `0`. The renderer consumes this ID mask for safe-area
|
| 4 |
+
//! layout, placement, and glyph collision checks against the real bubble shape.
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 5 |
|
| 6 |
use anyhow::Result;
|
| 7 |
use async_trait::async_trait;
|
| 8 |
use image::{DynamicImage, GrayImage, Luma};
|
| 9 |
use koharu_core::{MaskRole, Op};
|
| 10 |
+
use koharu_ml::speech_bubble_segmentation::{SpeechBubbleRegion, SpeechBubbleSegmentation};
|
| 11 |
|
| 12 |
use crate::pipeline::artifacts::Artifact;
|
| 13 |
use crate::pipeline::engine::{Engine, EngineCtx, EngineInfo};
|
|
|
|
| 21 |
let image = load_source_image(ctx.scene, ctx.page, ctx.blobs)?;
|
| 22 |
let result = self.0.inference(&image)?;
|
| 23 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
let mut regions: Vec<_> = result.regions.iter().collect();
|
| 25 |
regions.sort_by_key(|region| std::cmp::Reverse(region.area));
|
| 26 |
+
let mask = paint_bubble_id_mask(result.image_width, result.image_height, ®ions);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 27 |
|
| 28 |
let blob = ctx.blobs.put_webp(&DynamicImage::ImageLuma8(mask))?;
|
| 29 |
Ok(vec![upsert_mask_blob(
|
|
|
|
| 35 |
}
|
| 36 |
}
|
| 37 |
|
| 38 |
+
fn paint_bubble_id_mask(width: u32, height: u32, regions: &[&SpeechBubbleRegion]) -> GrayImage {
|
| 39 |
+
let mut mask = GrayImage::from_pixel(width, height, Luma([0u8]));
|
| 40 |
+
|
| 41 |
+
// Cap at 255 IDs; typical manga pages have well under 20 bubbles.
|
| 42 |
+
// Larger bubbles are painted first so smaller overlapping contours keep
|
| 43 |
+
// their own ID when painted later.
|
| 44 |
+
for (i, region) in regions.iter().take(255).enumerate() {
|
| 45 |
+
if region.mask.is_empty() {
|
| 46 |
+
continue;
|
| 47 |
+
}
|
| 48 |
+
let id = (i + 1) as u8;
|
| 49 |
+
let src_width = region.mask.width as usize;
|
| 50 |
+
let max_x = region.mask.width.min(width.saturating_sub(region.mask.x));
|
| 51 |
+
let max_y = region.mask.height.min(height.saturating_sub(region.mask.y));
|
| 52 |
+
for local_y in 0..max_y {
|
| 53 |
+
let src_row = local_y as usize * src_width;
|
| 54 |
+
let y = region.mask.y + local_y;
|
| 55 |
+
for local_x in 0..max_x {
|
| 56 |
+
if region.mask.pixels[src_row + local_x as usize] == 0 {
|
| 57 |
+
continue;
|
| 58 |
+
}
|
| 59 |
+
mask.put_pixel(region.mask.x + local_x, y, Luma([id]));
|
| 60 |
+
}
|
| 61 |
+
}
|
| 62 |
+
}
|
| 63 |
+
|
| 64 |
+
mask
|
| 65 |
+
}
|
| 66 |
+
|
| 67 |
inventory::submit! {
|
| 68 |
EngineInfo {
|
| 69 |
id: "speech-bubble-segmentation",
|
|
|
|
| 76 |
}),
|
| 77 |
}
|
| 78 |
}
|
| 79 |
+
|
| 80 |
+
#[cfg(test)]
|
| 81 |
+
mod tests {
|
| 82 |
+
use super::*;
|
| 83 |
+
use koharu_ml::speech_bubble_segmentation::{SpeechBubbleRegion, SpeechBubbleRegionMask};
|
| 84 |
+
|
| 85 |
+
#[test]
|
| 86 |
+
fn id_mask_paints_region_contours_not_bboxes() {
|
| 87 |
+
let region = SpeechBubbleRegion {
|
| 88 |
+
label_id: 0,
|
| 89 |
+
label: "bubble".to_string(),
|
| 90 |
+
score: 0.9,
|
| 91 |
+
bbox: [1.0, 1.0, 4.0, 4.0],
|
| 92 |
+
area: 3,
|
| 93 |
+
mask: SpeechBubbleRegionMask {
|
| 94 |
+
x: 1,
|
| 95 |
+
y: 1,
|
| 96 |
+
width: 3,
|
| 97 |
+
height: 3,
|
| 98 |
+
pixels: vec![0, 255, 0, 255, 255, 0, 0, 0, 0],
|
| 99 |
+
},
|
| 100 |
+
};
|
| 101 |
+
|
| 102 |
+
let mask = paint_bubble_id_mask(6, 6, &[®ion]);
|
| 103 |
+
|
| 104 |
+
assert_eq!(mask.get_pixel(1, 1).0[0], 0);
|
| 105 |
+
assert_eq!(mask.get_pixel(2, 1).0[0], 1);
|
| 106 |
+
assert_eq!(mask.get_pixel(1, 2).0[0], 1);
|
| 107 |
+
assert_eq!(mask.get_pixel(2, 2).0[0], 1);
|
| 108 |
+
assert_eq!(mask.get_pixel(3, 2).0[0], 0);
|
| 109 |
+
}
|
| 110 |
+
}
|
koharu-app/src/renderer.rs
CHANGED
|
@@ -252,7 +252,6 @@ impl Renderer {
|
|
| 252 |
.text_align
|
| 253 |
.map(core_align_to_renderer)
|
| 254 |
.unwrap_or(RendererTextAlign::Center);
|
| 255 |
-
let seed_box = resolved_box.seed_box;
|
| 256 |
let layout_box = resolved_box.layout_box;
|
| 257 |
|
| 258 |
let mut layout_builder = TextLayout::new(&font, None)
|
|
@@ -285,7 +284,7 @@ impl Renderer {
|
|
| 285 |
},
|
| 286 |
)?;
|
| 287 |
let transform = centred_sprite_transform(
|
| 288 |
-
|
| 289 |
rendered.width(),
|
| 290 |
rendered.height(),
|
| 291 |
block.transform.rotation_deg,
|
|
@@ -300,7 +299,6 @@ impl Renderer {
|
|
| 300 |
let candidate = fit_rendered_with_mask_collision(
|
| 301 |
&layout_builder,
|
| 302 |
translation,
|
| 303 |
-
writing_mode,
|
| 304 |
layout_box,
|
| 305 |
style.font_size,
|
| 306 |
min_font_size,
|
|
@@ -329,12 +327,6 @@ impl Renderer {
|
|
| 329 |
|
| 330 |
let candidate = render_candidate(&layout)?;
|
| 331 |
|
| 332 |
-
// Place the sprite centred on the *seed* (detector's original
|
| 333 |
-
// text bbox). The seed is always positioned where the source
|
| 334 |
-
// language placed the text — inside the bubble body, never on
|
| 335 |
-
// the tail — so anchoring here keeps translations in the body
|
| 336 |
-
// even when the bubble bbox extends into the tail area.
|
| 337 |
-
//
|
| 338 |
Ok(Some(RenderedBlock {
|
| 339 |
node_id: block.node_id,
|
| 340 |
sprite: DynamicImage::ImageRgba8(candidate.image),
|
|
@@ -370,8 +362,6 @@ impl Renderer {
|
|
| 370 |
// ---------------------------------------------------------------------------
|
| 371 |
|
| 372 |
const MASK_COLLISION_ALPHA_THRESHOLD: u8 = 8;
|
| 373 |
-
const COLLISION_SQUEEZE_FACTOR: f32 = 0.90;
|
| 374 |
-
const COLLISION_SQUEEZE_ATTEMPTS: usize = 3;
|
| 375 |
const FIT_EPSILON: f32 = 0.5;
|
| 376 |
|
| 377 |
struct RenderedTextCandidate {
|
|
@@ -379,9 +369,9 @@ struct RenderedTextCandidate {
|
|
| 379 |
transform: Transform,
|
| 380 |
}
|
| 381 |
|
| 382 |
-
struct
|
| 383 |
-
|
| 384 |
-
|
| 385 |
}
|
| 386 |
|
| 387 |
fn min_font_size_for_image(image_width: u32, image_height: u32) -> f32 {
|
|
@@ -457,7 +447,6 @@ fn fit_font_size<'a>(
|
|
| 457 |
fn fit_rendered_with_mask_collision<'a, F>(
|
| 458 |
layout_builder: &TextLayout<'a>,
|
| 459 |
text: &str,
|
| 460 |
-
writing_mode: WritingMode,
|
| 461 |
layout_box: LayoutBox,
|
| 462 |
explicit_size: Option<f32>,
|
| 463 |
min_size: f32,
|
|
@@ -469,54 +458,61 @@ fn fit_rendered_with_mask_collision<'a, F>(
|
|
| 469 |
where
|
| 470 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 471 |
{
|
| 472 |
-
let
|
| 473 |
-
|
| 474 |
-
|
| 475 |
-
|
| 476 |
-
|
| 477 |
-
|
| 478 |
-
|
| 479 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 480 |
|
| 481 |
-
let
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 482 |
layout_builder,
|
| 483 |
text,
|
| 484 |
-
writing_mode,
|
| 485 |
layout_box,
|
| 486 |
min_size as f32,
|
| 487 |
mask,
|
| 488 |
bubble_id,
|
| 489 |
render_candidate,
|
| 490 |
)?;
|
| 491 |
-
|
| 492 |
-
|
| 493 |
-
|
| 494 |
-
|
| 495 |
-
return render_collision_fallback(
|
| 496 |
-
layout_builder,
|
| 497 |
-
text,
|
| 498 |
-
writing_mode,
|
| 499 |
-
layout_box,
|
| 500 |
-
min_size as f32,
|
| 501 |
-
render_candidate,
|
| 502 |
-
);
|
| 503 |
-
};
|
| 504 |
|
| 505 |
let mut lo = min_size + 1;
|
| 506 |
-
let mut hi = max_size;
|
| 507 |
while lo <= hi {
|
| 508 |
let mid = lo + (hi - lo) / 2;
|
| 509 |
-
let
|
| 510 |
layout_builder,
|
| 511 |
text,
|
| 512 |
-
writing_mode,
|
| 513 |
layout_box,
|
| 514 |
mid as f32,
|
| 515 |
mask,
|
| 516 |
bubble_id,
|
| 517 |
render_candidate,
|
| 518 |
-
)?
|
| 519 |
-
if let Some(candidate) = attempt.valid {
|
| 520 |
best = candidate;
|
| 521 |
lo = mid + 1;
|
| 522 |
} else {
|
|
@@ -531,120 +527,71 @@ where
|
|
| 531 |
fn try_mask_collision_size<'a, F>(
|
| 532 |
layout_builder: &TextLayout<'a>,
|
| 533 |
text: &str,
|
| 534 |
-
writing_mode: WritingMode,
|
| 535 |
layout_box: LayoutBox,
|
| 536 |
font_size: f32,
|
| 537 |
mask: &GrayImage,
|
| 538 |
bubble_id: u8,
|
| 539 |
render_candidate: &mut F,
|
| 540 |
-
) -> Result<
|
| 541 |
where
|
| 542 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 543 |
{
|
| 544 |
-
let
|
| 545 |
-
|
| 546 |
-
|
| 547 |
-
|
| 548 |
-
text,
|
| 549 |
-
writing_mode,
|
| 550 |
-
layout_box,
|
| 551 |
-
font_size,
|
| 552 |
-
main_extent,
|
| 553 |
-
)?;
|
| 554 |
-
if !layout_fits_collision_attempt(&layout, writing_mode, layout_box, main_extent) {
|
| 555 |
-
continue;
|
| 556 |
-
}
|
| 557 |
-
|
| 558 |
-
let candidate = render_candidate(&layout)?;
|
| 559 |
-
if sprite_collides_with_bubble_mask(&candidate.image, &candidate.transform, mask, bubble_id)
|
| 560 |
-
{
|
| 561 |
-
fallback = Some(candidate);
|
| 562 |
-
continue;
|
| 563 |
-
}
|
| 564 |
-
return Ok(CollisionFitAttempt {
|
| 565 |
-
valid: Some(candidate),
|
| 566 |
-
fallback,
|
| 567 |
-
});
|
| 568 |
}
|
| 569 |
|
| 570 |
-
|
| 571 |
-
|
| 572 |
-
|
| 573 |
-
}
|
|
|
|
| 574 |
}
|
| 575 |
|
| 576 |
-
|
|
|
|
| 577 |
layout_builder: &TextLayout<'a>,
|
| 578 |
text: &str,
|
| 579 |
-
writing_mode: WritingMode,
|
| 580 |
layout_box: LayoutBox,
|
| 581 |
font_size: f32,
|
|
|
|
|
|
|
| 582 |
render_candidate: &mut F,
|
| 583 |
-
) -> Result<
|
| 584 |
where
|
| 585 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 586 |
{
|
| 587 |
-
let layout = run_collision_layout_at(
|
| 588 |
-
|
| 589 |
-
|
| 590 |
-
|
| 591 |
-
|
| 592 |
-
|
| 593 |
-
|
| 594 |
-
|
| 595 |
-
|
|
|
|
|
|
|
| 596 |
}
|
| 597 |
|
| 598 |
fn run_collision_layout_at<'a>(
|
| 599 |
layout_builder: &TextLayout<'a>,
|
| 600 |
text: &str,
|
| 601 |
-
writing_mode: WritingMode,
|
| 602 |
layout_box: LayoutBox,
|
| 603 |
font_size: f32,
|
| 604 |
-
main_extent: f32,
|
| 605 |
) -> Result<LayoutRun<'a>> {
|
| 606 |
-
let (max_width, max_height) = match writing_mode {
|
| 607 |
-
WritingMode::Horizontal => (main_extent, layout_box.height),
|
| 608 |
-
WritingMode::VerticalRl => (layout_box.width, main_extent),
|
| 609 |
-
};
|
| 610 |
layout_builder
|
| 611 |
.clone()
|
| 612 |
.with_font_size(font_size.max(1.0))
|
| 613 |
-
.with_max_width(
|
| 614 |
-
.with_max_height(
|
| 615 |
.run(text)
|
| 616 |
}
|
| 617 |
|
| 618 |
-
fn layout_fits_collision_attempt(
|
| 619 |
-
layout
|
| 620 |
-
|
| 621 |
-
layout_box: LayoutBox,
|
| 622 |
-
main_extent: f32,
|
| 623 |
-
) -> bool {
|
| 624 |
-
let fits_box = layout.width <= layout_box.width + FIT_EPSILON
|
| 625 |
-
&& layout.height <= layout_box.height + FIT_EPSILON;
|
| 626 |
-
let fits_main = match writing_mode {
|
| 627 |
-
WritingMode::Horizontal => layout.width <= main_extent + FIT_EPSILON,
|
| 628 |
-
WritingMode::VerticalRl => layout.height <= main_extent + FIT_EPSILON,
|
| 629 |
-
};
|
| 630 |
-
fits_box && fits_main
|
| 631 |
-
}
|
| 632 |
-
|
| 633 |
-
fn collision_squeeze_extents(layout_box: LayoutBox, writing_mode: WritingMode) -> Vec<f32> {
|
| 634 |
-
let mut extents = Vec::with_capacity(COLLISION_SQUEEZE_ATTEMPTS);
|
| 635 |
-
let mut extent = primary_collision_extent(layout_box, writing_mode);
|
| 636 |
-
for _ in 0..COLLISION_SQUEEZE_ATTEMPTS {
|
| 637 |
-
extents.push(extent.max(1.0));
|
| 638 |
-
extent *= COLLISION_SQUEEZE_FACTOR;
|
| 639 |
-
}
|
| 640 |
-
extents
|
| 641 |
-
}
|
| 642 |
-
|
| 643 |
-
fn primary_collision_extent(layout_box: LayoutBox, writing_mode: WritingMode) -> f32 {
|
| 644 |
-
match writing_mode {
|
| 645 |
-
WritingMode::Horizontal => layout_box.width,
|
| 646 |
-
WritingMode::VerticalRl => layout_box.height,
|
| 647 |
-
}
|
| 648 |
}
|
| 649 |
|
| 650 |
fn sprite_collides_with_bubble_mask(
|
|
@@ -722,9 +669,9 @@ fn resolve_layout_boxes(
|
|
| 722 |
.into_iter()
|
| 723 |
.map(|(seed_box, bubble_match)| match bubble_match {
|
| 724 |
// Connected bubbles can contain multiple independently detected
|
| 725 |
-
// text blocks. Expanding all of them to the same
|
| 726 |
-
// their layouts collide, so shared bubbles
|
| 727 |
-
//
|
| 728 |
Some(matched) if counts.get(&matched.id).copied().unwrap_or(0) == 1 => {
|
| 729 |
ResolvedLayoutBox {
|
| 730 |
seed_box,
|
|
@@ -934,8 +881,6 @@ fn centred_sprite_transform(
|
|
| 934 |
sprite_height: u32,
|
| 935 |
rotation_deg: f32,
|
| 936 |
) -> Transform {
|
| 937 |
-
// Centering on the anchor_box (usually the detector's seed_box)
|
| 938 |
-
// maintains original positioning and avoids tail drift.
|
| 939 |
let sprite_w = sprite_width as f32;
|
| 940 |
let sprite_h = sprite_height as f32;
|
| 941 |
let cx = anchor_box.x + anchor_box.width * 0.5;
|
|
@@ -1062,7 +1007,53 @@ mod tests {
|
|
| 1062 |
}
|
| 1063 |
|
| 1064 |
#[test]
|
| 1065 |
-
fn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1066 |
let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
|
| 1067 |
paint_rect(&mut mask, 10, 10, 190, 190, 1);
|
| 1068 |
let index = BubbleIndex::new(mask);
|
|
@@ -1139,25 +1130,6 @@ mod tests {
|
|
| 1139 |
));
|
| 1140 |
}
|
| 1141 |
|
| 1142 |
-
#[test]
|
| 1143 |
-
fn collision_squeeze_extents_retry_the_primary_axis() {
|
| 1144 |
-
let layout_box = LayoutBox {
|
| 1145 |
-
x: 0.0,
|
| 1146 |
-
y: 0.0,
|
| 1147 |
-
width: 100.0,
|
| 1148 |
-
height: 50.0,
|
| 1149 |
-
};
|
| 1150 |
-
|
| 1151 |
-
assert_eq!(
|
| 1152 |
-
collision_squeeze_extents(layout_box, WritingMode::Horizontal),
|
| 1153 |
-
vec![100.0, 90.0, 81.0]
|
| 1154 |
-
);
|
| 1155 |
-
assert_eq!(
|
| 1156 |
-
collision_squeeze_extents(layout_box, WritingMode::VerticalRl),
|
| 1157 |
-
vec![50.0, 45.0, 40.5]
|
| 1158 |
-
);
|
| 1159 |
-
}
|
| 1160 |
-
|
| 1161 |
fn block(x: f32, y: f32, width: f32, height: f32, translation: &str) -> RenderBlockInput {
|
| 1162 |
RenderBlockInput {
|
| 1163 |
node_id: NodeId::new(),
|
|
@@ -1185,6 +1157,50 @@ mod tests {
|
|
| 1185 |
}
|
| 1186 |
}
|
| 1187 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1188 |
#[test]
|
| 1189 |
fn centred_sprite_transform_anchors_to_provided_box_center() {
|
| 1190 |
let anchor = LayoutBox {
|
|
|
|
| 252 |
.text_align
|
| 253 |
.map(core_align_to_renderer)
|
| 254 |
.unwrap_or(RendererTextAlign::Center);
|
|
|
|
| 255 |
let layout_box = resolved_box.layout_box;
|
| 256 |
|
| 257 |
let mut layout_builder = TextLayout::new(&font, None)
|
|
|
|
| 284 |
},
|
| 285 |
)?;
|
| 286 |
let transform = centred_sprite_transform(
|
| 287 |
+
layout_box,
|
| 288 |
rendered.width(),
|
| 289 |
rendered.height(),
|
| 290 |
block.transform.rotation_deg,
|
|
|
|
| 299 |
let candidate = fit_rendered_with_mask_collision(
|
| 300 |
&layout_builder,
|
| 301 |
translation,
|
|
|
|
| 302 |
layout_box,
|
| 303 |
style.font_size,
|
| 304 |
min_font_size,
|
|
|
|
| 327 |
|
| 328 |
let candidate = render_candidate(&layout)?;
|
| 329 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 330 |
Ok(Some(RenderedBlock {
|
| 331 |
node_id: block.node_id,
|
| 332 |
sprite: DynamicImage::ImageRgba8(candidate.image),
|
|
|
|
| 362 |
// ---------------------------------------------------------------------------
|
| 363 |
|
| 364 |
const MASK_COLLISION_ALPHA_THRESHOLD: u8 = 8;
|
|
|
|
|
|
|
| 365 |
const FIT_EPSILON: f32 = 0.5;
|
| 366 |
|
| 367 |
struct RenderedTextCandidate {
|
|
|
|
| 369 |
transform: Transform,
|
| 370 |
}
|
| 371 |
|
| 372 |
+
struct MaskCollisionAttempt {
|
| 373 |
+
candidate: RenderedTextCandidate,
|
| 374 |
+
valid: bool,
|
| 375 |
}
|
| 376 |
|
| 377 |
fn min_font_size_for_image(image_width: u32, image_height: u32) -> f32 {
|
|
|
|
| 447 |
fn fit_rendered_with_mask_collision<'a, F>(
|
| 448 |
layout_builder: &TextLayout<'a>,
|
| 449 |
text: &str,
|
|
|
|
| 450 |
layout_box: LayoutBox,
|
| 451 |
explicit_size: Option<f32>,
|
| 452 |
min_size: f32,
|
|
|
|
| 458 |
where
|
| 459 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 460 |
{
|
| 461 |
+
if let Some(size) = explicit_size {
|
| 462 |
+
let attempt = render_mask_collision_attempt(
|
| 463 |
+
layout_builder,
|
| 464 |
+
text,
|
| 465 |
+
layout_box,
|
| 466 |
+
size.max(1.0),
|
| 467 |
+
mask,
|
| 468 |
+
bubble_id,
|
| 469 |
+
render_candidate,
|
| 470 |
+
)?;
|
| 471 |
+
return Ok(attempt.candidate);
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
let min_size = min_size.max(1.0).round() as i32;
|
| 475 |
+
let max_size = (max_size.max(1.0).round() as i32).max(min_size);
|
| 476 |
|
| 477 |
+
if let Some(candidate) = try_mask_collision_size(
|
| 478 |
+
layout_builder,
|
| 479 |
+
text,
|
| 480 |
+
layout_box,
|
| 481 |
+
max_size as f32,
|
| 482 |
+
mask,
|
| 483 |
+
bubble_id,
|
| 484 |
+
render_candidate,
|
| 485 |
+
)? {
|
| 486 |
+
return Ok(candidate);
|
| 487 |
+
}
|
| 488 |
+
|
| 489 |
+
let min_attempt = render_mask_collision_attempt(
|
| 490 |
layout_builder,
|
| 491 |
text,
|
|
|
|
| 492 |
layout_box,
|
| 493 |
min_size as f32,
|
| 494 |
mask,
|
| 495 |
bubble_id,
|
| 496 |
render_candidate,
|
| 497 |
)?;
|
| 498 |
+
if !min_attempt.valid {
|
| 499 |
+
return Ok(min_attempt.candidate);
|
| 500 |
+
}
|
| 501 |
+
let mut best = min_attempt.candidate;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 502 |
|
| 503 |
let mut lo = min_size + 1;
|
| 504 |
+
let mut hi = max_size - 1;
|
| 505 |
while lo <= hi {
|
| 506 |
let mid = lo + (hi - lo) / 2;
|
| 507 |
+
if let Some(candidate) = try_mask_collision_size(
|
| 508 |
layout_builder,
|
| 509 |
text,
|
|
|
|
| 510 |
layout_box,
|
| 511 |
mid as f32,
|
| 512 |
mask,
|
| 513 |
bubble_id,
|
| 514 |
render_candidate,
|
| 515 |
+
)? {
|
|
|
|
| 516 |
best = candidate;
|
| 517 |
lo = mid + 1;
|
| 518 |
} else {
|
|
|
|
| 527 |
fn try_mask_collision_size<'a, F>(
|
| 528 |
layout_builder: &TextLayout<'a>,
|
| 529 |
text: &str,
|
|
|
|
| 530 |
layout_box: LayoutBox,
|
| 531 |
font_size: f32,
|
| 532 |
mask: &GrayImage,
|
| 533 |
bubble_id: u8,
|
| 534 |
render_candidate: &mut F,
|
| 535 |
+
) -> Result<Option<RenderedTextCandidate>>
|
| 536 |
where
|
| 537 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 538 |
{
|
| 539 |
+
let layout = run_collision_layout_at(layout_builder, text, layout_box, font_size)?;
|
| 540 |
+
let fits_layout_box = layout_fits_collision_attempt(&layout, layout_box);
|
| 541 |
+
if !fits_layout_box {
|
| 542 |
+
return Ok(None);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 543 |
}
|
| 544 |
|
| 545 |
+
let candidate = render_candidate(&layout)?;
|
| 546 |
+
if sprite_collides_with_bubble_mask(&candidate.image, &candidate.transform, mask, bubble_id) {
|
| 547 |
+
return Ok(None);
|
| 548 |
+
}
|
| 549 |
+
Ok(Some(candidate))
|
| 550 |
}
|
| 551 |
|
| 552 |
+
#[allow(clippy::too_many_arguments)]
|
| 553 |
+
fn render_mask_collision_attempt<'a, F>(
|
| 554 |
layout_builder: &TextLayout<'a>,
|
| 555 |
text: &str,
|
|
|
|
| 556 |
layout_box: LayoutBox,
|
| 557 |
font_size: f32,
|
| 558 |
+
mask: &GrayImage,
|
| 559 |
+
bubble_id: u8,
|
| 560 |
render_candidate: &mut F,
|
| 561 |
+
) -> Result<MaskCollisionAttempt>
|
| 562 |
where
|
| 563 |
F: FnMut(&LayoutRun<'a>) -> Result<RenderedTextCandidate>,
|
| 564 |
{
|
| 565 |
+
let layout = run_collision_layout_at(layout_builder, text, layout_box, font_size)?;
|
| 566 |
+
let fits_layout_box = layout_fits_collision_attempt(&layout, layout_box);
|
| 567 |
+
let candidate = render_candidate(&layout)?;
|
| 568 |
+
let valid = fits_layout_box
|
| 569 |
+
&& !sprite_collides_with_bubble_mask(
|
| 570 |
+
&candidate.image,
|
| 571 |
+
&candidate.transform,
|
| 572 |
+
mask,
|
| 573 |
+
bubble_id,
|
| 574 |
+
);
|
| 575 |
+
Ok(MaskCollisionAttempt { candidate, valid })
|
| 576 |
}
|
| 577 |
|
| 578 |
fn run_collision_layout_at<'a>(
|
| 579 |
layout_builder: &TextLayout<'a>,
|
| 580 |
text: &str,
|
|
|
|
| 581 |
layout_box: LayoutBox,
|
| 582 |
font_size: f32,
|
|
|
|
| 583 |
) -> Result<LayoutRun<'a>> {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 584 |
layout_builder
|
| 585 |
.clone()
|
| 586 |
.with_font_size(font_size.max(1.0))
|
| 587 |
+
.with_max_width(layout_box.width.max(1.0))
|
| 588 |
+
.with_max_height(layout_box.height.max(1.0))
|
| 589 |
.run(text)
|
| 590 |
}
|
| 591 |
|
| 592 |
+
fn layout_fits_collision_attempt(layout: &LayoutRun<'_>, layout_box: LayoutBox) -> bool {
|
| 593 |
+
layout.width <= layout_box.width + FIT_EPSILON
|
| 594 |
+
&& layout.height <= layout_box.height + FIT_EPSILON
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
}
|
| 596 |
|
| 597 |
fn sprite_collides_with_bubble_mask(
|
|
|
|
| 669 |
.into_iter()
|
| 670 |
.map(|(seed_box, bubble_match)| match bubble_match {
|
| 671 |
// Connected bubbles can contain multiple independently detected
|
| 672 |
+
// text blocks. Expanding all of them to the same safe area makes
|
| 673 |
+
// their layouts collide, so shared bubbles keep each block's
|
| 674 |
+
// original detector box.
|
| 675 |
Some(matched) if counts.get(&matched.id).copied().unwrap_or(0) == 1 => {
|
| 676 |
ResolvedLayoutBox {
|
| 677 |
seed_box,
|
|
|
|
| 881 |
sprite_height: u32,
|
| 882 |
rotation_deg: f32,
|
| 883 |
) -> Transform {
|
|
|
|
|
|
|
| 884 |
let sprite_w = sprite_width as f32;
|
| 885 |
let sprite_h = sprite_height as f32;
|
| 886 |
let cx = anchor_box.x + anchor_box.width * 0.5;
|
|
|
|
| 1007 |
}
|
| 1008 |
|
| 1009 |
#[test]
|
| 1010 |
+
fn mask_collision_fit_renders_min_size_when_no_safe_size_exists() -> Result<()> {
|
| 1011 |
+
let font = any_system_font();
|
| 1012 |
+
let layout_builder = TextLayout::new(&font, None);
|
| 1013 |
+
let layout_box = LayoutBox {
|
| 1014 |
+
x: 0.0,
|
| 1015 |
+
y: 0.0,
|
| 1016 |
+
width: 24.0,
|
| 1017 |
+
height: 12.0,
|
| 1018 |
+
};
|
| 1019 |
+
let mask = GrayImage::from_pixel(64, 64, Luma([0u8]));
|
| 1020 |
+
let mut rendered_sizes = Vec::new();
|
| 1021 |
+
let mut render_candidate = |layout: &LayoutRun<'_>| -> Result<RenderedTextCandidate> {
|
| 1022 |
+
rendered_sizes.push(layout.font_size);
|
| 1023 |
+
let width = layout.width.ceil().max(1.0) as u32;
|
| 1024 |
+
let height = layout.height.ceil().max(1.0) as u32;
|
| 1025 |
+
Ok(RenderedTextCandidate {
|
| 1026 |
+
image: RgbaImage::from_pixel(width, height, Rgba([0, 0, 0, 255])),
|
| 1027 |
+
transform: Transform {
|
| 1028 |
+
x: 0.0,
|
| 1029 |
+
y: 0.0,
|
| 1030 |
+
width: width as f32,
|
| 1031 |
+
height: height as f32,
|
| 1032 |
+
rotation_deg: 0.0,
|
| 1033 |
+
},
|
| 1034 |
+
})
|
| 1035 |
+
};
|
| 1036 |
+
|
| 1037 |
+
let candidate = fit_rendered_with_mask_collision(
|
| 1038 |
+
&layout_builder,
|
| 1039 |
+
"overflowing text",
|
| 1040 |
+
layout_box,
|
| 1041 |
+
None,
|
| 1042 |
+
12.0,
|
| 1043 |
+
18.0,
|
| 1044 |
+
&mask,
|
| 1045 |
+
1,
|
| 1046 |
+
&mut render_candidate,
|
| 1047 |
+
)?;
|
| 1048 |
+
|
| 1049 |
+
assert_eq!(rendered_sizes.last().copied(), Some(12.0));
|
| 1050 |
+
assert!(candidate.image.width() >= 1);
|
| 1051 |
+
assert!(candidate.image.height() >= 1);
|
| 1052 |
+
Ok(())
|
| 1053 |
+
}
|
| 1054 |
+
|
| 1055 |
+
#[test]
|
| 1056 |
+
fn shared_bubble_keeps_seed_boxes_to_avoid_overlap() {
|
| 1057 |
let mut mask = GrayImage::from_pixel(200, 200, Luma([0u8]));
|
| 1058 |
paint_rect(&mut mask, 10, 10, 190, 190, 1);
|
| 1059 |
let index = BubbleIndex::new(mask);
|
|
|
|
| 1130 |
));
|
| 1131 |
}
|
| 1132 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1133 |
fn block(x: f32, y: f32, width: f32, height: f32, translation: &str) -> RenderBlockInput {
|
| 1134 |
RenderBlockInput {
|
| 1135 |
node_id: NodeId::new(),
|
|
|
|
| 1157 |
}
|
| 1158 |
}
|
| 1159 |
|
| 1160 |
+
fn any_system_font() -> Font {
|
| 1161 |
+
let mut book = FontBook::new();
|
| 1162 |
+
let preferred = [
|
| 1163 |
+
"Yu Gothic",
|
| 1164 |
+
"MS Gothic",
|
| 1165 |
+
"Noto Sans CJK JP",
|
| 1166 |
+
"Noto Sans",
|
| 1167 |
+
"Arial",
|
| 1168 |
+
"DejaVu Sans",
|
| 1169 |
+
"Liberation Sans",
|
| 1170 |
+
];
|
| 1171 |
+
|
| 1172 |
+
for name in preferred {
|
| 1173 |
+
if let Some(post_script_name) = book
|
| 1174 |
+
.all_families()
|
| 1175 |
+
.into_iter()
|
| 1176 |
+
.find(|face| {
|
| 1177 |
+
face.post_script_name == name
|
| 1178 |
+
|| face
|
| 1179 |
+
.families
|
| 1180 |
+
.iter()
|
| 1181 |
+
.any(|(family, _)| family.as_str() == name)
|
| 1182 |
+
})
|
| 1183 |
+
.map(|face| face.post_script_name)
|
| 1184 |
+
.filter(|post_script_name| !post_script_name.is_empty())
|
| 1185 |
+
&& let Ok(font) = book.query(&post_script_name)
|
| 1186 |
+
{
|
| 1187 |
+
return font;
|
| 1188 |
+
}
|
| 1189 |
+
}
|
| 1190 |
+
|
| 1191 |
+
if let Some(face) = book
|
| 1192 |
+
.all_families()
|
| 1193 |
+
.into_iter()
|
| 1194 |
+
.find(|face| !face.post_script_name.is_empty())
|
| 1195 |
+
{
|
| 1196 |
+
return book
|
| 1197 |
+
.query(&face.post_script_name)
|
| 1198 |
+
.expect("failed to load first system font");
|
| 1199 |
+
}
|
| 1200 |
+
|
| 1201 |
+
panic!("no system font available for tests");
|
| 1202 |
+
}
|
| 1203 |
+
|
| 1204 |
#[test]
|
| 1205 |
fn centred_sprite_transform_anchors_to_provided_box_center() {
|
| 1206 |
let anchor = LayoutBox {
|
koharu-ml/src/speech_bubble_segmentation/mod.rs
CHANGED
|
@@ -68,6 +68,31 @@ pub struct SpeechBubbleSegmentationResult {
|
|
| 68 |
pub probability_map: ProbabilityMap,
|
| 69 |
}
|
| 70 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
#[derive(Debug, Clone, Serialize)]
|
| 72 |
#[serde(rename_all = "camelCase")]
|
| 73 |
pub struct SpeechBubbleRegion {
|
|
@@ -76,6 +101,8 @@ pub struct SpeechBubbleRegion {
|
|
| 76 |
pub score: f32,
|
| 77 |
pub bbox: [f32; 4],
|
| 78 |
pub area: u32,
|
|
|
|
|
|
|
| 79 |
}
|
| 80 |
|
| 81 |
#[derive(Debug, Clone)]
|
|
@@ -332,18 +359,22 @@ fn postprocess(
|
|
| 332 |
|
| 333 |
let mut regions = Vec::with_capacity(raw_regions.len());
|
| 334 |
for (region, mask) in raw_regions.iter().zip(mask_probabilities.iter()) {
|
| 335 |
-
let area =
|
| 336 |
&mut probability_map,
|
| 337 |
mask,
|
| 338 |
region.bbox,
|
| 339 |
config.mask_threshold,
|
| 340 |
-
);
|
|
|
|
|
|
|
|
|
|
| 341 |
regions.push(SpeechBubbleRegion {
|
| 342 |
label_id: region.label_id,
|
| 343 |
label: region.label.clone(),
|
| 344 |
score: region.score,
|
| 345 |
bbox: region.bbox,
|
| 346 |
area,
|
|
|
|
| 347 |
});
|
| 348 |
}
|
| 349 |
|
|
@@ -519,16 +550,20 @@ fn mask_crop_window(
|
|
| 519 |
(top, left, bottom, right)
|
| 520 |
}
|
| 521 |
|
| 522 |
-
fn
|
| 523 |
probability_map: &mut ProbabilityMap,
|
| 524 |
mask: &[f32],
|
| 525 |
bbox: [f32; 4],
|
| 526 |
threshold: f32,
|
| 527 |
-
) -> u32 {
|
| 528 |
let width = probability_map.width as usize;
|
| 529 |
let height = probability_map.height as usize;
|
| 530 |
if mask.len() != width * height {
|
| 531 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 532 |
}
|
| 533 |
|
| 534 |
let x1 = bbox[0].floor().clamp(0.0, probability_map.width as f32) as usize;
|
|
@@ -536,29 +571,46 @@ fn merge_mask_into_probability_map(
|
|
| 536 |
let x2 = bbox[2].ceil().clamp(0.0, probability_map.width as f32) as usize;
|
| 537 |
let y2 = bbox[3].ceil().clamp(0.0, probability_map.height as f32) as usize;
|
| 538 |
if x2 <= x1 || y2 <= y1 {
|
| 539 |
-
return 0;
|
| 540 |
}
|
| 541 |
|
|
|
|
|
|
|
|
|
|
| 542 |
let mut area = 0u32;
|
| 543 |
for y in y1..y2.min(height) {
|
| 544 |
let row_offset = y * width;
|
|
|
|
| 545 |
for x in x1..x2.min(width) {
|
| 546 |
let idx = row_offset + x;
|
| 547 |
let value = mask[idx];
|
| 548 |
if value >= threshold {
|
| 549 |
area += 1;
|
|
|
|
| 550 |
}
|
| 551 |
if value > probability_map.values[idx] {
|
| 552 |
probability_map.values[idx] = value;
|
| 553 |
}
|
| 554 |
}
|
| 555 |
}
|
| 556 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 557 |
}
|
| 558 |
|
| 559 |
#[cfg(test)]
|
| 560 |
mod tests {
|
| 561 |
-
use super::{
|
|
|
|
|
|
|
|
|
|
| 562 |
use candle_core::{DType, Device, Tensor};
|
| 563 |
|
| 564 |
#[test]
|
|
@@ -587,4 +639,27 @@ mod tests {
|
|
| 587 |
let (top, left, bottom, right) = mask_crop_window(1000, 500, 160, 160);
|
| 588 |
assert_eq!((top, left, bottom, right), (40, 0, 120, 160));
|
| 589 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 590 |
}
|
|
|
|
| 68 |
pub probability_map: ProbabilityMap,
|
| 69 |
}
|
| 70 |
|
| 71 |
+
#[derive(Debug, Clone)]
|
| 72 |
+
pub struct SpeechBubbleRegionMask {
|
| 73 |
+
pub x: u32,
|
| 74 |
+
pub y: u32,
|
| 75 |
+
pub width: u32,
|
| 76 |
+
pub height: u32,
|
| 77 |
+
pub pixels: Vec<u8>,
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
impl SpeechBubbleRegionMask {
|
| 81 |
+
pub fn empty(x: u32, y: u32) -> Self {
|
| 82 |
+
Self {
|
| 83 |
+
x,
|
| 84 |
+
y,
|
| 85 |
+
width: 0,
|
| 86 |
+
height: 0,
|
| 87 |
+
pixels: Vec::new(),
|
| 88 |
+
}
|
| 89 |
+
}
|
| 90 |
+
|
| 91 |
+
pub fn is_empty(&self) -> bool {
|
| 92 |
+
self.width == 0 || self.height == 0 || self.pixels.is_empty()
|
| 93 |
+
}
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
#[derive(Debug, Clone, Serialize)]
|
| 97 |
#[serde(rename_all = "camelCase")]
|
| 98 |
pub struct SpeechBubbleRegion {
|
|
|
|
| 101 |
pub score: f32,
|
| 102 |
pub bbox: [f32; 4],
|
| 103 |
pub area: u32,
|
| 104 |
+
#[serde(skip_serializing)]
|
| 105 |
+
pub mask: SpeechBubbleRegionMask,
|
| 106 |
}
|
| 107 |
|
| 108 |
#[derive(Debug, Clone)]
|
|
|
|
| 359 |
|
| 360 |
let mut regions = Vec::with_capacity(raw_regions.len());
|
| 361 |
for (region, mask) in raw_regions.iter().zip(mask_probabilities.iter()) {
|
| 362 |
+
let (area, region_mask) = extract_region_contour_mask(
|
| 363 |
&mut probability_map,
|
| 364 |
mask,
|
| 365 |
region.bbox,
|
| 366 |
config.mask_threshold,
|
| 367 |
+
)?;
|
| 368 |
+
if area == 0 {
|
| 369 |
+
continue;
|
| 370 |
+
}
|
| 371 |
regions.push(SpeechBubbleRegion {
|
| 372 |
label_id: region.label_id,
|
| 373 |
label: region.label.clone(),
|
| 374 |
score: region.score,
|
| 375 |
bbox: region.bbox,
|
| 376 |
area,
|
| 377 |
+
mask: region_mask,
|
| 378 |
});
|
| 379 |
}
|
| 380 |
|
|
|
|
| 550 |
(top, left, bottom, right)
|
| 551 |
}
|
| 552 |
|
| 553 |
+
fn extract_region_contour_mask(
|
| 554 |
probability_map: &mut ProbabilityMap,
|
| 555 |
mask: &[f32],
|
| 556 |
bbox: [f32; 4],
|
| 557 |
threshold: f32,
|
| 558 |
+
) -> Result<(u32, SpeechBubbleRegionMask)> {
|
| 559 |
let width = probability_map.width as usize;
|
| 560 |
let height = probability_map.height as usize;
|
| 561 |
if mask.len() != width * height {
|
| 562 |
+
bail!(
|
| 563 |
+
"speech bubble mask length {} does not match image area {}",
|
| 564 |
+
mask.len(),
|
| 565 |
+
width * height
|
| 566 |
+
);
|
| 567 |
}
|
| 568 |
|
| 569 |
let x1 = bbox[0].floor().clamp(0.0, probability_map.width as f32) as usize;
|
|
|
|
| 571 |
let x2 = bbox[2].ceil().clamp(0.0, probability_map.width as f32) as usize;
|
| 572 |
let y2 = bbox[3].ceil().clamp(0.0, probability_map.height as f32) as usize;
|
| 573 |
if x2 <= x1 || y2 <= y1 {
|
| 574 |
+
return Ok((0, SpeechBubbleRegionMask::empty(x1 as u32, y1 as u32)));
|
| 575 |
}
|
| 576 |
|
| 577 |
+
let mask_width = x2 - x1;
|
| 578 |
+
let mask_height = y2 - y1;
|
| 579 |
+
let mut pixels = vec![0u8; mask_width * mask_height];
|
| 580 |
let mut area = 0u32;
|
| 581 |
for y in y1..y2.min(height) {
|
| 582 |
let row_offset = y * width;
|
| 583 |
+
let local_row_offset = (y - y1) * mask_width;
|
| 584 |
for x in x1..x2.min(width) {
|
| 585 |
let idx = row_offset + x;
|
| 586 |
let value = mask[idx];
|
| 587 |
if value >= threshold {
|
| 588 |
area += 1;
|
| 589 |
+
pixels[local_row_offset + (x - x1)] = u8::MAX;
|
| 590 |
}
|
| 591 |
if value > probability_map.values[idx] {
|
| 592 |
probability_map.values[idx] = value;
|
| 593 |
}
|
| 594 |
}
|
| 595 |
}
|
| 596 |
+
Ok((
|
| 597 |
+
area,
|
| 598 |
+
SpeechBubbleRegionMask {
|
| 599 |
+
x: x1 as u32,
|
| 600 |
+
y: y1 as u32,
|
| 601 |
+
width: mask_width as u32,
|
| 602 |
+
height: mask_height as u32,
|
| 603 |
+
pixels,
|
| 604 |
+
},
|
| 605 |
+
))
|
| 606 |
}
|
| 607 |
|
| 608 |
#[cfg(test)]
|
| 609 |
mod tests {
|
| 610 |
+
use super::{
|
| 611 |
+
PreparedInput, extract_region_contour_mask, map_bbox_to_original, mask_crop_window,
|
| 612 |
+
};
|
| 613 |
+
use crate::probability_map::ProbabilityMap;
|
| 614 |
use candle_core::{DType, Device, Tensor};
|
| 615 |
|
| 616 |
#[test]
|
|
|
|
| 639 |
let (top, left, bottom, right) = mask_crop_window(1000, 500, 160, 160);
|
| 640 |
assert_eq!((top, left, bottom, right), (40, 0, 120, 160));
|
| 641 |
}
|
| 642 |
+
|
| 643 |
+
#[test]
|
| 644 |
+
fn extract_region_contour_mask_keeps_thresholded_shape() -> anyhow::Result<()> {
|
| 645 |
+
let mut probability_map = ProbabilityMap::zeros(6, 5);
|
| 646 |
+
let mut mask = vec![0.0f32; 6 * 5];
|
| 647 |
+
mask[1 + 1 * 6] = 0.9;
|
| 648 |
+
mask[2 + 1 * 6] = 0.8;
|
| 649 |
+
mask[2 + 2 * 6] = 0.7;
|
| 650 |
+
mask[4 + 3 * 6] = 0.4;
|
| 651 |
+
|
| 652 |
+
let (area, region_mask) =
|
| 653 |
+
extract_region_contour_mask(&mut probability_map, &mask, [1.0, 1.0, 5.0, 4.0], 0.5)?;
|
| 654 |
+
|
| 655 |
+
assert_eq!(area, 3);
|
| 656 |
+
assert_eq!((region_mask.x, region_mask.y), (1, 1));
|
| 657 |
+
assert_eq!((region_mask.width, region_mask.height), (4, 3));
|
| 658 |
+
assert_eq!(region_mask.pixels[0], u8::MAX);
|
| 659 |
+
assert_eq!(region_mask.pixels[1], u8::MAX);
|
| 660 |
+
assert_eq!(region_mask.pixels[5], u8::MAX);
|
| 661 |
+
assert_eq!(region_mask.pixels[11], 0);
|
| 662 |
+
assert_eq!(probability_map.values[4 + 3 * 6], 0.4);
|
| 663 |
+
Ok(())
|
| 664 |
+
}
|
| 665 |
}
|
koharu-renderer/src/layout.rs
CHANGED
|
@@ -19,6 +19,8 @@ pub use crate::segment::{LineBreakSuffix, hyphenation_lang_from_tag};
|
|
| 19 |
pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
|
| 20 |
|
| 21 |
const HYPHENATION_MIN_WORD_LEN: usize = 8;
|
|
|
|
|
|
|
| 22 |
|
| 23 |
/// Writing mode for text layout.
|
| 24 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
@@ -65,6 +67,34 @@ pub struct LayoutRun<'a> {
|
|
| 65 |
pub font_size: f32,
|
| 66 |
}
|
| 67 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 68 |
#[derive(Clone)]
|
| 69 |
pub struct TextLayout<'a> {
|
| 70 |
writing_mode: WritingMode,
|
|
@@ -236,92 +266,9 @@ impl<'a> TextLayout<'a> {
|
|
| 236 |
.unwrap_or(f32::INFINITY);
|
| 237 |
let max_extent_finite = max_extent.is_finite() && max_extent > 0.0;
|
| 238 |
|
| 239 |
-
let segments = line_breaker.line_segments(text);
|
| 240 |
-
|
| 241 |
let mut fonts: Vec<&Font> = Vec::with_capacity(1 + self.fallback_fonts.len());
|
| 242 |
fonts.push(self.font);
|
| 243 |
fonts.extend(self.fallback_fonts.iter());
|
| 244 |
-
let mut lines: Vec<LayoutLine<'a>> = Vec::new();
|
| 245 |
-
|
| 246 |
-
// Temporary storage for runs in the current line to be reordered
|
| 247 |
-
struct LineRun<'a> {
|
| 248 |
-
shaped: ShapedRun<'a>,
|
| 249 |
-
level: unicode_bidi::Level,
|
| 250 |
-
}
|
| 251 |
-
struct ShapedBreakSuffix<'a> {
|
| 252 |
-
runs: Vec<LineRun<'a>>,
|
| 253 |
-
advance: f32,
|
| 254 |
-
}
|
| 255 |
-
let mut current_line_runs: Vec<LineRun<'a>> = Vec::new();
|
| 256 |
-
let mut line_offset = 0usize;
|
| 257 |
-
let mut current_advance = 0.0f32;
|
| 258 |
-
let mut current_break_suffix: Option<ShapedBreakSuffix<'a>> = None;
|
| 259 |
-
|
| 260 |
-
let finalize_current_line = |runs: &mut Vec<LineRun<'a>>,
|
| 261 |
-
offset: &mut usize,
|
| 262 |
-
visible_end: usize,
|
| 263 |
-
next_offset: usize,
|
| 264 |
-
lines: &mut Vec<LayoutLine<'a>>,
|
| 265 |
-
break_suffix: Option<ShapedBreakSuffix<'a>>,
|
| 266 |
-
force_push: bool| {
|
| 267 |
-
if runs.is_empty() && !force_push {
|
| 268 |
-
*offset = next_offset;
|
| 269 |
-
return;
|
| 270 |
-
}
|
| 271 |
-
|
| 272 |
-
if let Some(mut suffix) = break_suffix {
|
| 273 |
-
runs.append(&mut suffix.runs);
|
| 274 |
-
}
|
| 275 |
-
|
| 276 |
-
let levels: Vec<unicode_bidi::Level> = runs.iter().map(|r| r.level).collect();
|
| 277 |
-
let visual_indices = reorder_visual(&levels);
|
| 278 |
-
|
| 279 |
-
let mut line = LayoutLine {
|
| 280 |
-
range: *offset..visible_end,
|
| 281 |
-
direction: if self.writing_mode.is_vertical() {
|
| 282 |
-
harfrust::Direction::TopToBottom
|
| 283 |
-
} else {
|
| 284 |
-
bidi_info
|
| 285 |
-
.paragraphs
|
| 286 |
-
.iter()
|
| 287 |
-
.find(|p| *offset >= p.range.start && *offset <= p.range.end)
|
| 288 |
-
.map(|p| {
|
| 289 |
-
if p.level.is_rtl() {
|
| 290 |
-
harfrust::Direction::RightToLeft
|
| 291 |
-
} else {
|
| 292 |
-
harfrust::Direction::LeftToRight
|
| 293 |
-
}
|
| 294 |
-
})
|
| 295 |
-
.unwrap_or(harfrust::Direction::LeftToRight)
|
| 296 |
-
},
|
| 297 |
-
..Default::default()
|
| 298 |
-
};
|
| 299 |
-
|
| 300 |
-
let mut pen_x = 0.0f32;
|
| 301 |
-
let mut pen_y = 0.0f32;
|
| 302 |
-
|
| 303 |
-
for idx in visual_indices {
|
| 304 |
-
let run = &mut runs[idx];
|
| 305 |
-
for glyph in std::mem::take(&mut run.shaped.glyphs) {
|
| 306 |
-
line.glyphs.push(glyph);
|
| 307 |
-
}
|
| 308 |
-
if self.writing_mode.is_vertical() {
|
| 309 |
-
pen_y -= run.shaped.y_advance;
|
| 310 |
-
} else {
|
| 311 |
-
pen_x += run.shaped.x_advance;
|
| 312 |
-
}
|
| 313 |
-
}
|
| 314 |
-
|
| 315 |
-
line.advance = if self.writing_mode.is_vertical() {
|
| 316 |
-
pen_y.abs()
|
| 317 |
-
} else {
|
| 318 |
-
pen_x
|
| 319 |
-
};
|
| 320 |
-
|
| 321 |
-
lines.push(line);
|
| 322 |
-
runs.clear();
|
| 323 |
-
*offset = next_offset;
|
| 324 |
-
};
|
| 325 |
|
| 326 |
let shape_break_suffix = |suffix: LineBreakSuffix,
|
| 327 |
level: unicode_bidi::Level,
|
|
@@ -340,14 +287,15 @@ impl<'a> TextLayout<'a> {
|
|
| 340 |
for glyph in &mut shaped.glyphs {
|
| 341 |
glyph.cluster += cluster as u32;
|
| 342 |
}
|
| 343 |
-
advance += shaped.x_advance;
|
| 344 |
runs.push(LineRun { shaped, level });
|
| 345 |
}
|
| 346 |
|
| 347 |
Ok(ShapedBreakSuffix { runs, advance })
|
| 348 |
};
|
| 349 |
|
| 350 |
-
|
|
|
|
| 351 |
let segment_text = &text[segment.range.clone()];
|
| 352 |
|
| 353 |
let mut segment_runs = Vec::new();
|
|
@@ -397,9 +345,9 @@ impl<'a> TextLayout<'a> {
|
|
| 397 |
}
|
| 398 |
|
| 399 |
segment_advance += if self.writing_mode.is_vertical() {
|
| 400 |
-
shaped.y_advance
|
| 401 |
} else {
|
| 402 |
-
shaped.x_advance
|
| 403 |
};
|
| 404 |
|
| 405 |
segment_runs.push(LineRun { shaped, level });
|
|
@@ -414,62 +362,45 @@ impl<'a> TextLayout<'a> {
|
|
| 414 |
None
|
| 415 |
};
|
| 416 |
|
| 417 |
-
|
| 418 |
-
|
| 419 |
-
|
| 420 |
-
|
| 421 |
-
|
| 422 |
-
|
| 423 |
-
|
| 424 |
-
};
|
| 425 |
-
|
| 426 |
-
if would_overflow && !current_line_runs.is_empty() {
|
| 427 |
-
finalize_current_line(
|
| 428 |
-
&mut current_line_runs,
|
| 429 |
-
&mut line_offset,
|
| 430 |
-
segment.range.start,
|
| 431 |
-
segment.range.start,
|
| 432 |
-
&mut lines,
|
| 433 |
-
current_break_suffix.take(),
|
| 434 |
-
false,
|
| 435 |
-
);
|
| 436 |
-
current_advance = 0.0;
|
| 437 |
-
}
|
| 438 |
|
| 439 |
-
|
| 440 |
-
|
| 441 |
-
|
| 442 |
-
|
| 443 |
-
if segment.is_mandatory {
|
| 444 |
-
|
| 445 |
-
// when segment.is_mandatory and current_line_runs is empty, finalize_current_line
|
| 446 |
-
// early-returns without pushing a LayoutLine. Consider special-casing mandatory
|
| 447 |
-
// breaks to push an empty LayoutLine (advance=0, glyphs empty, range=offset..visible_end)
|
| 448 |
-
// so blank lines are preserved.
|
| 449 |
-
finalize_current_line(
|
| 450 |
-
&mut current_line_runs,
|
| 451 |
-
&mut line_offset,
|
| 452 |
-
segment.range.end,
|
| 453 |
-
segment.next_offset,
|
| 454 |
-
&mut lines,
|
| 455 |
-
None,
|
| 456 |
-
true,
|
| 457 |
-
);
|
| 458 |
-
current_advance = 0.0;
|
| 459 |
-
current_break_suffix = None;
|
| 460 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 461 |
}
|
| 462 |
-
|
| 463 |
-
// Finalize last line
|
| 464 |
-
finalize_current_line(
|
| 465 |
-
&mut current_line_runs,
|
| 466 |
-
&mut line_offset,
|
| 467 |
-
text.len(),
|
| 468 |
-
text.len(),
|
| 469 |
-
&mut lines,
|
| 470 |
-
None,
|
| 471 |
-
false, // Don't force push a final empty line if the text didn't end with a break
|
| 472 |
-
);
|
| 473 |
|
| 474 |
// Baselines depend only on line index and metrics. For vertical text we compute absolute X
|
| 475 |
// positions within the layout bounds (0..width) so the renderer can draw from the left.
|
|
@@ -603,6 +534,153 @@ impl<'a> TextLayout<'a> {
|
|
| 603 |
})
|
| 604 |
}
|
| 605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 606 |
fn ink_bounds(&self, font_size: f32, lines: &[LayoutLine<'a>]) -> Option<(f32, f32, f32, f32)> {
|
| 607 |
let mut metrics_cache = HashMap::new();
|
| 608 |
|
|
@@ -699,6 +777,74 @@ impl<'a> TextLayout<'a> {
|
|
| 699 |
}
|
| 700 |
}
|
| 701 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 702 |
fn centered_x_offset(x_min: f32, x_max: f32) -> f32 {
|
| 703 |
-((x_min + x_max) * 0.5)
|
| 704 |
}
|
|
@@ -894,6 +1040,19 @@ mod tests {
|
|
| 894 |
);
|
| 895 |
}
|
| 896 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 897 |
#[test]
|
| 898 |
fn layout_baselines_horizontal_follow_font_metrics() -> anyhow::Result<()> {
|
| 899 |
let font = any_system_font();
|
|
|
|
| 19 |
pub use crate::shape::{PositionedGlyph, ShapedRun, ShapingOptions, TextShaper};
|
| 20 |
|
| 21 |
const HYPHENATION_MIN_WORD_LEN: usize = 8;
|
| 22 |
+
const LINE_BREAK_HYPHEN_PENALTY: f32 = 2_000.0;
|
| 23 |
+
const LINE_BREAK_OVERFLOW_MULTIPLIER: f32 = 10_000.0;
|
| 24 |
|
| 25 |
/// Writing mode for text layout.
|
| 26 |
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
|
|
|
|
| 67 |
pub font_size: f32,
|
| 68 |
}
|
| 69 |
|
| 70 |
+
#[derive(Clone)]
|
| 71 |
+
struct LineRun<'a> {
|
| 72 |
+
shaped: ShapedRun<'a>,
|
| 73 |
+
level: unicode_bidi::Level,
|
| 74 |
+
}
|
| 75 |
+
|
| 76 |
+
#[derive(Clone)]
|
| 77 |
+
struct ShapedBreakSuffix<'a> {
|
| 78 |
+
runs: Vec<LineRun<'a>>,
|
| 79 |
+
advance: f32,
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
#[derive(Clone)]
|
| 83 |
+
struct ShapedSegment<'a> {
|
| 84 |
+
range: Range<usize>,
|
| 85 |
+
next_offset: usize,
|
| 86 |
+
is_mandatory: bool,
|
| 87 |
+
runs: Vec<LineRun<'a>>,
|
| 88 |
+
advance: f32,
|
| 89 |
+
break_suffix: Option<ShapedBreakSuffix<'a>>,
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
#[derive(Clone, Copy, Debug)]
|
| 93 |
+
struct LineBreakMeasure {
|
| 94 |
+
advance: f32,
|
| 95 |
+
break_suffix_advance: f32,
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
#[derive(Clone)]
|
| 99 |
pub struct TextLayout<'a> {
|
| 100 |
writing_mode: WritingMode,
|
|
|
|
| 266 |
.unwrap_or(f32::INFINITY);
|
| 267 |
let max_extent_finite = max_extent.is_finite() && max_extent > 0.0;
|
| 268 |
|
|
|
|
|
|
|
| 269 |
let mut fonts: Vec<&Font> = Vec::with_capacity(1 + self.fallback_fonts.len());
|
| 270 |
fonts.push(self.font);
|
| 271 |
fonts.extend(self.fallback_fonts.iter());
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 272 |
|
| 273 |
let shape_break_suffix = |suffix: LineBreakSuffix,
|
| 274 |
level: unicode_bidi::Level,
|
|
|
|
| 287 |
for glyph in &mut shaped.glyphs {
|
| 288 |
glyph.cluster += cluster as u32;
|
| 289 |
}
|
| 290 |
+
advance += shaped.x_advance.abs();
|
| 291 |
runs.push(LineRun { shaped, level });
|
| 292 |
}
|
| 293 |
|
| 294 |
Ok(ShapedBreakSuffix { runs, advance })
|
| 295 |
};
|
| 296 |
|
| 297 |
+
let mut shaped_segments = Vec::new();
|
| 298 |
+
for segment in line_breaker.line_segments(text) {
|
| 299 |
let segment_text = &text[segment.range.clone()];
|
| 300 |
|
| 301 |
let mut segment_runs = Vec::new();
|
|
|
|
| 345 |
}
|
| 346 |
|
| 347 |
segment_advance += if self.writing_mode.is_vertical() {
|
| 348 |
+
shaped.y_advance.abs()
|
| 349 |
} else {
|
| 350 |
+
shaped.x_advance.abs()
|
| 351 |
};
|
| 352 |
|
| 353 |
segment_runs.push(LineRun { shaped, level });
|
|
|
|
| 362 |
None
|
| 363 |
};
|
| 364 |
|
| 365 |
+
shaped_segments.push(ShapedSegment {
|
| 366 |
+
range: segment.range,
|
| 367 |
+
next_offset: segment.next_offset,
|
| 368 |
+
is_mandatory: segment.is_mandatory,
|
| 369 |
+
runs: segment_runs,
|
| 370 |
+
advance: segment_advance,
|
| 371 |
+
break_suffix: segment_break_suffix,
|
| 372 |
+
});
|
| 373 |
+
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 374 |
|
| 375 |
+
let mut lines: Vec<LayoutLine<'a>> = Vec::new();
|
| 376 |
+
let mut line_offset = 0usize;
|
| 377 |
+
let mut paragraph_start = 0usize;
|
| 378 |
+
for (index, segment) in shaped_segments.iter().enumerate() {
|
| 379 |
+
if !segment.is_mandatory {
|
| 380 |
+
continue;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 381 |
}
|
| 382 |
+
self.append_balanced_segment_lines(
|
| 383 |
+
&shaped_segments[paragraph_start..=index],
|
| 384 |
+
&mut line_offset,
|
| 385 |
+
segment.next_offset,
|
| 386 |
+
true,
|
| 387 |
+
max_extent,
|
| 388 |
+
&bidi_info,
|
| 389 |
+
&mut lines,
|
| 390 |
+
);
|
| 391 |
+
paragraph_start = index + 1;
|
| 392 |
+
}
|
| 393 |
+
if paragraph_start < shaped_segments.len() {
|
| 394 |
+
self.append_balanced_segment_lines(
|
| 395 |
+
&shaped_segments[paragraph_start..],
|
| 396 |
+
&mut line_offset,
|
| 397 |
+
text.len(),
|
| 398 |
+
false,
|
| 399 |
+
max_extent,
|
| 400 |
+
&bidi_info,
|
| 401 |
+
&mut lines,
|
| 402 |
+
);
|
| 403 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 404 |
|
| 405 |
// Baselines depend only on line index and metrics. For vertical text we compute absolute X
|
| 406 |
// positions within the layout bounds (0..width) so the renderer can draw from the left.
|
|
|
|
| 534 |
})
|
| 535 |
}
|
| 536 |
|
| 537 |
+
#[allow(clippy::too_many_arguments)]
|
| 538 |
+
fn append_balanced_segment_lines(
|
| 539 |
+
&self,
|
| 540 |
+
segments: &[ShapedSegment<'a>],
|
| 541 |
+
line_offset: &mut usize,
|
| 542 |
+
final_next_offset: usize,
|
| 543 |
+
force_final_line: bool,
|
| 544 |
+
max_extent: f32,
|
| 545 |
+
bidi_info: &BidiInfo<'_>,
|
| 546 |
+
lines: &mut Vec<LayoutLine<'a>>,
|
| 547 |
+
) {
|
| 548 |
+
if segments.is_empty() {
|
| 549 |
+
if force_final_line {
|
| 550 |
+
*line_offset = self.push_layout_line(
|
| 551 |
+
Vec::new(),
|
| 552 |
+
*line_offset,
|
| 553 |
+
*line_offset,
|
| 554 |
+
final_next_offset,
|
| 555 |
+
None,
|
| 556 |
+
true,
|
| 557 |
+
bidi_info,
|
| 558 |
+
lines,
|
| 559 |
+
);
|
| 560 |
+
}
|
| 561 |
+
return;
|
| 562 |
+
}
|
| 563 |
+
|
| 564 |
+
let break_indices = if max_extent.is_finite() && max_extent > 0.0 {
|
| 565 |
+
let measures = segments
|
| 566 |
+
.iter()
|
| 567 |
+
.map(|segment| LineBreakMeasure {
|
| 568 |
+
advance: segment.advance,
|
| 569 |
+
break_suffix_advance: segment
|
| 570 |
+
.break_suffix
|
| 571 |
+
.as_ref()
|
| 572 |
+
.map_or(0.0, |suffix| suffix.advance),
|
| 573 |
+
})
|
| 574 |
+
.collect::<Vec<_>>();
|
| 575 |
+
optimal_line_breaks(&measures, max_extent)
|
| 576 |
+
} else {
|
| 577 |
+
vec![segments.len()]
|
| 578 |
+
};
|
| 579 |
+
|
| 580 |
+
let mut start = 0usize;
|
| 581 |
+
for end in break_indices {
|
| 582 |
+
if end <= start || end > segments.len() {
|
| 583 |
+
continue;
|
| 584 |
+
}
|
| 585 |
+
let final_line = end == segments.len();
|
| 586 |
+
let visible_end = segments[end - 1].range.end;
|
| 587 |
+
let next_offset = if final_line {
|
| 588 |
+
final_next_offset
|
| 589 |
+
} else {
|
| 590 |
+
segments[end].range.start
|
| 591 |
+
};
|
| 592 |
+
let break_suffix = if final_line {
|
| 593 |
+
None
|
| 594 |
+
} else {
|
| 595 |
+
segments[end - 1].break_suffix.clone()
|
| 596 |
+
};
|
| 597 |
+
let runs = segments[start..end]
|
| 598 |
+
.iter()
|
| 599 |
+
.flat_map(|segment| segment.runs.iter().cloned())
|
| 600 |
+
.collect::<Vec<_>>();
|
| 601 |
+
*line_offset = self.push_layout_line(
|
| 602 |
+
runs,
|
| 603 |
+
*line_offset,
|
| 604 |
+
visible_end,
|
| 605 |
+
next_offset,
|
| 606 |
+
break_suffix,
|
| 607 |
+
force_final_line && final_line,
|
| 608 |
+
bidi_info,
|
| 609 |
+
lines,
|
| 610 |
+
);
|
| 611 |
+
start = end;
|
| 612 |
+
}
|
| 613 |
+
}
|
| 614 |
+
|
| 615 |
+
#[allow(clippy::too_many_arguments)]
|
| 616 |
+
fn push_layout_line(
|
| 617 |
+
&self,
|
| 618 |
+
mut runs: Vec<LineRun<'a>>,
|
| 619 |
+
offset: usize,
|
| 620 |
+
visible_end: usize,
|
| 621 |
+
next_offset: usize,
|
| 622 |
+
break_suffix: Option<ShapedBreakSuffix<'a>>,
|
| 623 |
+
force_push: bool,
|
| 624 |
+
bidi_info: &BidiInfo<'_>,
|
| 625 |
+
lines: &mut Vec<LayoutLine<'a>>,
|
| 626 |
+
) -> usize {
|
| 627 |
+
if runs.is_empty() && !force_push {
|
| 628 |
+
return next_offset;
|
| 629 |
+
}
|
| 630 |
+
|
| 631 |
+
if let Some(mut suffix) = break_suffix {
|
| 632 |
+
runs.append(&mut suffix.runs);
|
| 633 |
+
}
|
| 634 |
+
|
| 635 |
+
let levels: Vec<unicode_bidi::Level> = runs.iter().map(|r| r.level).collect();
|
| 636 |
+
let visual_indices = reorder_visual(&levels);
|
| 637 |
+
|
| 638 |
+
let mut line = LayoutLine {
|
| 639 |
+
range: offset..visible_end,
|
| 640 |
+
direction: if self.writing_mode.is_vertical() {
|
| 641 |
+
harfrust::Direction::TopToBottom
|
| 642 |
+
} else {
|
| 643 |
+
bidi_info
|
| 644 |
+
.paragraphs
|
| 645 |
+
.iter()
|
| 646 |
+
.find(|p| offset >= p.range.start && offset <= p.range.end)
|
| 647 |
+
.map(|p| {
|
| 648 |
+
if p.level.is_rtl() {
|
| 649 |
+
harfrust::Direction::RightToLeft
|
| 650 |
+
} else {
|
| 651 |
+
harfrust::Direction::LeftToRight
|
| 652 |
+
}
|
| 653 |
+
})
|
| 654 |
+
.unwrap_or(harfrust::Direction::LeftToRight)
|
| 655 |
+
},
|
| 656 |
+
..Default::default()
|
| 657 |
+
};
|
| 658 |
+
|
| 659 |
+
let mut pen_x = 0.0f32;
|
| 660 |
+
let mut pen_y = 0.0f32;
|
| 661 |
+
|
| 662 |
+
for idx in visual_indices {
|
| 663 |
+
let run = &mut runs[idx];
|
| 664 |
+
for glyph in std::mem::take(&mut run.shaped.glyphs) {
|
| 665 |
+
line.glyphs.push(glyph);
|
| 666 |
+
}
|
| 667 |
+
if self.writing_mode.is_vertical() {
|
| 668 |
+
pen_y -= run.shaped.y_advance;
|
| 669 |
+
} else {
|
| 670 |
+
pen_x += run.shaped.x_advance;
|
| 671 |
+
}
|
| 672 |
+
}
|
| 673 |
+
|
| 674 |
+
line.advance = if self.writing_mode.is_vertical() {
|
| 675 |
+
pen_y.abs()
|
| 676 |
+
} else {
|
| 677 |
+
pen_x
|
| 678 |
+
};
|
| 679 |
+
|
| 680 |
+
lines.push(line);
|
| 681 |
+
next_offset
|
| 682 |
+
}
|
| 683 |
+
|
| 684 |
fn ink_bounds(&self, font_size: f32, lines: &[LayoutLine<'a>]) -> Option<(f32, f32, f32, f32)> {
|
| 685 |
let mut metrics_cache = HashMap::new();
|
| 686 |
|
|
|
|
| 777 |
}
|
| 778 |
}
|
| 779 |
|
| 780 |
+
fn optimal_line_breaks(segments: &[LineBreakMeasure], max_extent: f32) -> Vec<usize> {
|
| 781 |
+
let len = segments.len();
|
| 782 |
+
if len == 0 {
|
| 783 |
+
return Vec::new();
|
| 784 |
+
}
|
| 785 |
+
if !max_extent.is_finite() || max_extent <= 0.0 {
|
| 786 |
+
return vec![len];
|
| 787 |
+
}
|
| 788 |
+
|
| 789 |
+
let mut dp = vec![f32::INFINITY; len + 1];
|
| 790 |
+
let mut prev = vec![None; len + 1];
|
| 791 |
+
dp[0] = 0.0;
|
| 792 |
+
|
| 793 |
+
for start in 0..len {
|
| 794 |
+
if !dp[start].is_finite() {
|
| 795 |
+
continue;
|
| 796 |
+
}
|
| 797 |
+
let mut advance = 0.0f32;
|
| 798 |
+
for end in start + 1..=len {
|
| 799 |
+
advance += segments[end - 1].advance;
|
| 800 |
+
let suffix_advance = if end < len {
|
| 801 |
+
segments[end - 1].break_suffix_advance
|
| 802 |
+
} else {
|
| 803 |
+
0.0
|
| 804 |
+
};
|
| 805 |
+
let line_advance = advance + suffix_advance;
|
| 806 |
+
let is_single_segment = end == start + 1;
|
| 807 |
+
if line_advance > max_extent && !is_single_segment {
|
| 808 |
+
break;
|
| 809 |
+
}
|
| 810 |
+
|
| 811 |
+
let mut cost = dp[start] + line_break_badness(line_advance, max_extent);
|
| 812 |
+
if end < len && suffix_advance > 0.0 {
|
| 813 |
+
cost += LINE_BREAK_HYPHEN_PENALTY;
|
| 814 |
+
}
|
| 815 |
+
|
| 816 |
+
if cost < dp[end] {
|
| 817 |
+
dp[end] = cost;
|
| 818 |
+
prev[end] = Some(start);
|
| 819 |
+
}
|
| 820 |
+
}
|
| 821 |
+
}
|
| 822 |
+
|
| 823 |
+
if !dp[len].is_finite() {
|
| 824 |
+
return vec![len];
|
| 825 |
+
}
|
| 826 |
+
|
| 827 |
+
let mut breaks = Vec::new();
|
| 828 |
+
let mut index = len;
|
| 829 |
+
while index > 0 {
|
| 830 |
+
breaks.push(index);
|
| 831 |
+
let Some(previous) = prev[index] else {
|
| 832 |
+
return vec![len];
|
| 833 |
+
};
|
| 834 |
+
index = previous;
|
| 835 |
+
}
|
| 836 |
+
breaks.reverse();
|
| 837 |
+
breaks
|
| 838 |
+
}
|
| 839 |
+
|
| 840 |
+
fn line_break_badness(line_advance: f32, max_extent: f32) -> f32 {
|
| 841 |
+
if line_advance <= max_extent {
|
| 842 |
+
(max_extent - line_advance).powi(3)
|
| 843 |
+
} else {
|
| 844 |
+
(line_advance - max_extent).powi(3) * LINE_BREAK_OVERFLOW_MULTIPLIER
|
| 845 |
+
}
|
| 846 |
+
}
|
| 847 |
+
|
| 848 |
fn centered_x_offset(x_min: f32, x_max: f32) -> f32 {
|
| 849 |
-((x_min + x_max) * 0.5)
|
| 850 |
}
|
|
|
|
| 1040 |
);
|
| 1041 |
}
|
| 1042 |
|
| 1043 |
+
#[test]
|
| 1044 |
+
fn optimal_line_breaks_balance_ragged_lines() {
|
| 1045 |
+
let segments = vec![
|
| 1046 |
+
LineBreakMeasure {
|
| 1047 |
+
advance: 30.0,
|
| 1048 |
+
break_suffix_advance: 0.0,
|
| 1049 |
+
};
|
| 1050 |
+
7
|
| 1051 |
+
];
|
| 1052 |
+
|
| 1053 |
+
assert_eq!(optimal_line_breaks(&segments, 100.0), vec![2, 4, 7]);
|
| 1054 |
+
}
|
| 1055 |
+
|
| 1056 |
#[test]
|
| 1057 |
fn layout_baselines_horizontal_follow_font_metrics() -> anyhow::Result<()> {
|
| 1058 |
let font = any_system_font();
|