jbig2enc_rust/jbig2cc.rs
1//! Run-length based connected component analysis for JBIG2 encoding.
2//!
3//! This module is a Rust port of the algorithm from DjVuLibre's `cjb2.cpp`,
4//! adapted for JBIG2 symbol dictionary creation. It uses run-length encoding
5//! of scanlines + single-pass union-find to discover connected components.
6//! This approach is dramatically faster and more memory-efficient than
7//! pixel-list algorithms (like Lutz) because:
8//!
9//! 1. **Runs** compress horizontal spans into (y, x1, x2) triples — a typical
10//! document page might have ~50 000 runs vs millions of pixels.
11//! 2. **Union-find** with path compression gives near-O(n) labeling.
12//! 3. **Reading-order sort** groups components into text lines, which is
13//! critical for efficient dictionary encoding (similar shapes appear near
14//! each other).
15//!
16//! ## Integration with JBIG2 Encoder
17//!
18//! This module is enabled with the `symboldict` feature flag. When enabled,
19//! it replaces the Lutz-based pixel-list approach in jbig2lutz.rs:
20//!
21//! ```rust,ignore
22//! #[cfg(feature = "symboldict")]
23//! use crate::jbig2cc::{analyze_page, CCImage};
24//! use crate::jbig2sym::BitImage;
25//!
26//! // 1. Load or create your bilevel page image
27//! let page_image: BitImage = BitImage::new(2550, 3300)?;
28//! // ... fill with scanned document data ...
29//!
30//! // 2. Run connected component analysis
31//! let dpi = 300; // Image resolution
32//! let losslevel = 1; // 0 = lossless, >0 = enable cleaning
33//! let cc_image = analyze_page(&page_image, dpi, losslevel);
34//!
35//! // 3. Extract shapes for symbol dictionary
36//! let shapes = cc_image.extract_shapes();
37//!
38//! // 4. Build JBIG2 symbol dictionary from shapes
39//! // (shapes are now (BitImage, BBox) pairs ready for symbol matching)
40//! ```
41//!
42//! ## Feature Flag Usage
43//!
44//! To enable this module, add to your Cargo.toml:
45//! ```toml
46//! [dependencies]
47//! Legencode = { path = "../Legencode", features = ["symboldict"] }
48//! ```
49//!
50//! ## Differences from DjVu JB2 Version
51//!
52//! The only differences from the JB2 version in DJVULibRust are:
53//! 1. Import path: uses `jbig2sym::BitImage` instead of `jb2::symbol_dict::BitImage`
54//! 2. Both BitImage implementations provide the same API: `new()`, `get_pixel_unchecked()`, `set_usize()`
55//! 3. Coordinate systems are identical (top-down y-axis)
56//!
57//! ## DjVuLibre license notice
58//!
59//! The original C++ code is Copyright (c) 2002 Leon Bottou and Yann Le Cun,
60//! distributed under the GNU General Public License v2+. This Rust port
61//! preserves the algorithmic structure but is a clean-room reimplementation
62//! of the public API and data flow described in the DjVu specification.
63
64use crate::jbig2sym::BitImage;
65
66// ─── Run ────────────────────────────────────────────────────────────────────
67
68/// A horizontal run of foreground (black) pixels on a single scanline.
69#[derive(Clone, Debug)]
70pub struct Run {
71 /// Vertical coordinate (row). y = 0 is the **top** of the image in our
72 /// coordinate system; cjb2.cpp uses bottom-up, but we canonicalize to
73 /// top-down since `BitImage` is top-down. The algorithm is symmetric.
74 pub y: i32,
75 /// First (leftmost) horizontal coordinate of the run, inclusive.
76 pub x1: i32,
77 /// Last (rightmost) horizontal coordinate of the run, inclusive.
78 pub x2: i32,
79 /// Connected-component id assigned during analysis.
80 pub ccid: i32,
81}
82
83impl Run {
84 /// Ordering used when sorting: primary by y ascending, secondary by x1.
85 fn sort_key(&self) -> (i32, i32) {
86 (self.y, self.x1)
87 }
88}
89
90// ─── CC descriptor ──────────────────────────────────────────────────────────
91
92/// Bounding box with (xmin, ymin) inclusive and (xmax, ymax) exclusive,
93/// matching DjVuLibre's `GRect` convention.
94#[derive(Clone, Copy, Debug, Default)]
95pub struct BBox {
96 pub xmin: i32,
97 pub ymin: i32,
98 /// Exclusive right edge.
99 pub xmax: i32,
100 /// Exclusive bottom edge.
101 pub ymax: i32,
102}
103
104impl BBox {
105 pub fn width(&self) -> i32 {
106 self.xmax - self.xmin
107 }
108 pub fn height(&self) -> i32 {
109 self.ymax - self.ymin
110 }
111}
112
113/// Descriptor for a single connected component, exactly matching DjVuLibre's `CC`.
114#[derive(Clone, Debug, Default)]
115pub struct CC {
116 /// Bounding box (xmin/ymin inclusive, xmax/ymax exclusive).
117 pub bb: BBox,
118 /// Total number of foreground pixels in this CC.
119 pub npix: i32,
120 /// Number of runs belonging to this CC.
121 pub nrun: i32,
122 /// Index of the first run in the sorted runs array.
123 pub frun: i32,
124}
125
126/// Lightweight handle for a connected component before bitmap materialization.
127#[derive(Clone, Copy, Debug)]
128pub struct ShapeRef {
129 pub ccid: usize,
130 pub bbox: BBox,
131 pub black_pixels: usize,
132 pub run_count: usize,
133}
134
135// ─── CCImage ────────────────────────────────────────────────────────────────
136
137/// An image decomposed into runs, with connected-component analysis,
138/// cleaning, merging/splitting, and reading-order sort — matching the full
139/// pipeline of `cjb2.cpp`'s `CCImage` class.
140pub struct CCImage {
141 pub width: i32,
142 pub height: i32,
143 pub runs: Vec<Run>,
144 pub ccs: Vec<CC>,
145 /// Number of "regular" CCs (text-sized). CCs at indices ≥ nregularccs
146 /// are "special" (merged small fragments or split large regions).
147 pub nregularccs: usize,
148 /// CCs whose bounding box exceeds this in either dimension get split.
149 pub largesize: i32,
150 /// CCs whose bounding box is ≤ this in both dimensions get merged.
151 pub smallsize: i32,
152 /// CCs with ≤ this many pixels get erased (noise removal).
153 pub tinysize: i32,
154}
155
156impl CCImage {
157 // ── Construction ─────────────────────────────────────────────────────
158
159 /// Create a new empty `CCImage` with DPI-aware thresholds.
160 ///
161 /// The thresholds match cjb2.cpp exactly:
162 /// ```text
163 /// dpi = clamp(dpi, 200, 900)
164 /// largesize = min(500, max(64, dpi))
165 /// smallsize = max(2, dpi / 150)
166 /// tinysize = max(0, dpi² / 20000 − 1)
167 /// ```
168 pub fn new(width: i32, height: i32, dpi: i32) -> Self {
169 let dpi = dpi.max(200).min(900);
170 Self {
171 width,
172 height,
173 runs: Vec::new(),
174 ccs: Vec::new(),
175 nregularccs: 0,
176 largesize: 500.min(64.max(dpi)),
177 smallsize: 2.max(dpi / 150),
178 tinysize: 0.max(dpi * dpi / 20000 - 1),
179 }
180 }
181
182 // ── Run extraction ──────────────────────────────────────────────────
183
184 /// Add a single run.
185 pub fn add_single_run(&mut self, y: i32, x1: i32, x2: i32) {
186 self.runs.push(Run { y, x1, x2, ccid: 0 });
187 }
188
189 /// Extract all horizontal runs from a `BitImage`.
190 ///
191 /// This replaces the Lutz pixel-list approach. For a 2550×3300 page
192 /// at 300 DPI the run list is typically 40–80 k entries, versus tens
193 /// of millions of pixel tuples.
194 pub fn add_bitmap_runs(&mut self, bm: &BitImage) {
195 for y in 0..bm.height {
196 let row_start = y * bm.width;
197 let row_bits = &bm.as_bits()[row_start..row_start + bm.width];
198 let mut x = 0usize;
199
200 while x < bm.width {
201 // Skip to the next black pixel using word-level scan (64 bits/cycle)
202 if let Some(black_offset) = row_bits[x..].first_one() {
203 let x1 = x + black_offset;
204 // Find the end of this black run
205 let run_length = row_bits[x1..].first_zero().unwrap_or(bm.width - x1);
206 let x2 = x1 + run_length - 1;
207
208 self.add_single_run(y as i32, x1 as i32, x2 as i32);
209 x = x2 + 1;
210 } else {
211 break; // No more black pixels in this row
212 }
213 }
214 }
215 }
216
217 // ── Connected-component labeling (union-find on runs) ───────────────
218
219 /// Assign `ccid` to every run using single-pass union-find.
220 ///
221 /// This is a direct port of `CCImage::make_ccids_by_analysis()`.
222 ///
223 /// **Algorithm summary:**
224 /// 1. Sort runs by (y, x1).
225 /// 2. For each run on line y, scan the runs on line y−1 that horizontally
226 /// overlap (with 1-pixel adjacency, i.e. x1−1..x2+1).
227 /// 3. Union all overlapping previous-line runs with the current run.
228 /// 4. Path-compress the union-find map.
229 pub fn make_ccids_by_analysis(&mut self) {
230 // Sort runs
231 self.runs.sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
232
233 let n_runs = self.runs.len();
234 if n_runs == 0 {
235 return;
236 }
237
238 // Union-find map: umap[id] is the parent of id. A root satisfies
239 // umap[id] == id.
240 let mut umap: Vec<i32> = Vec::new();
241
242 // `p` is the pointer into runs for the "previous line" scan window.
243 let mut p: usize = 0;
244
245 for n in 0..n_runs {
246 let y = self.runs[n].y;
247 let x1 = self.runs[n].x1 - 1; // 1-pixel adjacency
248 let x2 = self.runs[n].x2 + 1;
249
250 // id will hold the representative for this run's CC.
251 // Initialize to "no id yet" by setting beyond current umap.
252 let mut id: i32 = umap.len() as i32;
253
254 // Advance p past runs that are above line y-1
255 while p < n_runs && self.runs[p].y < y - 1 {
256 p += 1;
257 }
258
259 // Scan previous-line runs that could overlap
260 let mut pp = p;
261 while pp < n_runs && self.runs[pp].y < y && self.runs[pp].x1 <= x2 {
262 if self.runs[pp].x2 >= x1 {
263 // This previous run overlaps — union.
264 let mut oid = self.runs[pp].ccid;
265 // Path compression: find root
266 while (oid as usize) < umap.len() && umap[oid as usize] < oid {
267 oid = umap[oid as usize];
268 }
269
270 if id >= umap.len() as i32 {
271 // First overlap: adopt the previous run's root
272 id = oid;
273 } else if id < oid {
274 // Merge: point oid → id
275 if (oid as usize) < umap.len() {
276 umap[oid as usize] = id;
277 }
278 } else if oid < id {
279 // Merge: point id → oid
280 if (id as usize) < umap.len() {
281 umap[id as usize] = oid;
282 }
283 id = oid;
284 }
285
286 // Freshen previous run's ccid
287 self.runs[pp].ccid = id;
288
289 // Stop if this previous run extends past our current run
290 if self.runs[pp].x2 >= x2 {
291 break;
292 }
293 }
294 pp += 1;
295 }
296
297 // Assign id to current run
298 self.runs[n].ccid = id;
299 if id >= umap.len() as i32 {
300 // Create a new root
301 let new_id = umap.len() as i32;
302 umap.push(new_id);
303 self.runs[n].ccid = new_id;
304 }
305 }
306
307 // Final path compression pass — flatten every ccid to its root
308 for n in 0..n_runs {
309 let mut ccid = self.runs[n].ccid;
310 while (ccid as usize) < umap.len() && umap[ccid as usize] < ccid {
311 ccid = umap[ccid as usize];
312 }
313 // Full path compression: also update intermediate nodes
314 let root = ccid;
315 let mut id = self.runs[n].ccid;
316 while id != root {
317 let next = umap[id as usize];
318 umap[id as usize] = root;
319 id = next;
320 }
321 self.runs[n].ccid = root;
322 }
323 }
324
325 // ── Build CC descriptors from labeled runs ──────────────────────────
326
327 /// Compute CC descriptors (bounding boxes, pixel counts, run ranges)
328 /// from the ccid labels on runs.
329 ///
330 /// Direct port of `CCImage::make_ccs_from_ccids()`.
331 pub fn make_ccs_from_ccids(&mut self) {
332 if self.runs.is_empty() {
333 self.ccs.clear();
334 return;
335 }
336
337 // Find maximum ccid
338 let mut maxccid = (self.nregularccs as i32) - 1;
339 for run in &self.runs {
340 if run.ccid > maxccid {
341 maxccid = run.ccid;
342 }
343 }
344 if maxccid < 0 {
345 self.ccs.clear();
346 return;
347 }
348
349 // Renumber: rmap[old_ccid] → new sequential id, or -1 if unused.
350 let map_size = (maxccid + 1) as usize;
351 let mut rmap = vec![-1i32; map_size];
352 for run in &self.runs {
353 if run.ccid >= 0 {
354 rmap[run.ccid as usize] = 1; // mark as used
355 }
356 }
357 let mut nid = 0i32;
358 for entry in rmap.iter_mut() {
359 if *entry > 0 {
360 *entry = nid;
361 nid += 1;
362 }
363 }
364
365 // Adjust nregularccs
366 while self.nregularccs > 0
367 && (self.nregularccs - 1 < map_size)
368 && rmap[self.nregularccs - 1] < 0
369 {
370 self.nregularccs -= 1;
371 }
372 if self.nregularccs > 0 && self.nregularccs <= map_size {
373 self.nregularccs = (1 + rmap[self.nregularccs - 1]) as usize;
374 }
375
376 // Initialize CC descriptors
377 let nid_us = nid as usize;
378 self.ccs = vec![CC::default(); nid_us];
379
380 // Count runs per CC
381 for run in &self.runs {
382 if run.ccid < 0 {
383 continue;
384 }
385 let new_id = rmap[run.ccid as usize];
386 if new_id >= 0 {
387 self.ccs[new_id as usize].nrun += 1;
388 }
389 }
390
391 // Compute first-run positions
392 let mut frun = 0i32;
393 // We'll reuse rmap as a "current insertion position" array
394 let mut positions = vec![0i32; nid_us];
395 for i in 0..nid_us {
396 self.ccs[i].frun = frun;
397 positions[i] = frun;
398 frun += self.ccs[i].nrun;
399 }
400
401 // Relabel runs and copy into sorted order
402 let mut sorted_runs = vec![
403 Run {
404 y: 0,
405 x1: 0,
406 x2: 0,
407 ccid: -1
408 };
409 frun as usize
410 ];
411 for run in &self.runs {
412 if run.ccid < 0 {
413 continue;
414 }
415 let new_id = rmap[run.ccid as usize];
416 if new_id < 0 {
417 continue;
418 }
419 let pos = positions[new_id as usize] as usize;
420 sorted_runs[pos] = Run {
421 y: run.y,
422 x1: run.x1,
423 x2: run.x2,
424 ccid: new_id,
425 };
426 positions[new_id as usize] += 1;
427 }
428 self.runs = sorted_runs;
429
430 // Finalize each CC: sort its runs and compute bounding box + npix
431 for i in 0..nid_us {
432 let cc = &self.ccs[i];
433 let start = cc.frun as usize;
434 let end = start + cc.nrun as usize;
435
436 // Sort runs within this CC
437 self.runs[start..end].sort_by(|a, b| a.sort_key().cmp(&b.sort_key()));
438
439 // Compute bounds and pixel count
440 let mut npix = 0i32;
441 let mut xmin = i32::MAX;
442 let mut xmax = i32::MIN;
443 let mut ymin = i32::MAX;
444 let mut ymax = i32::MIN;
445
446 for run in &self.runs[start..end] {
447 xmin = xmin.min(run.x1);
448 xmax = xmax.max(run.x2);
449 ymin = ymin.min(run.y);
450 ymax = ymax.max(run.y);
451 npix += run.x2 - run.x1 + 1;
452 }
453
454 let cc = &mut self.ccs[i];
455 cc.npix = npix;
456 cc.bb = BBox {
457 xmin,
458 ymin,
459 xmax: xmax + 1, // exclusive
460 ymax: ymax + 1, // exclusive
461 };
462 }
463 }
464
465 // ── Noise removal ───────────────────────────────────────────────────
466
467 /// Remove CCs with ≤ `tinysize` pixels.
468 ///
469 /// This is the "cleaning" step: at 300 DPI tinysize = 3, so isolated
470 /// specks of 1–3 pixels are removed. (cjb2.cpp notes that halftone
471 /// regions should be exempted, but neither cjb2 nor we do that.)
472 pub fn erase_tiny_ccs(&mut self) {
473 for i in 0..self.ccs.len() {
474 if self.ccs[i].npix <= self.tinysize {
475 let frun = self.ccs[i].frun as usize;
476 let nrun = self.ccs[i].nrun as usize;
477 self.ccs[i].nrun = 0;
478 self.ccs[i].npix = 0;
479 for r in frun..frun + nrun {
480 if r < self.runs.len() {
481 self.runs[r].ccid = -1;
482 }
483 }
484 }
485 }
486 }
487
488 // ── Merge small / split large CCs ───────────────────────────────────
489
490 /// The critical step that the Lutz-based code was missing entirely.
491 ///
492 /// ## Small CC merging
493 /// Any CC whose bounding box fits within `smallsize × smallsize` is
494 /// merged with other nearby small CCs in the same grid cell. The grid
495 /// cell size is `largesize`. This catches:
496 /// - Diacritical marks (dots over i/j, umlauts, tildes)
497 /// - Punctuation fragments
498 /// - Serif fragments that separated during binarization
499 ///
500 /// ## Large CC splitting
501 /// Any CC whose bounding box exceeds `largesize` in either dimension
502 /// has its runs re-assigned to grid cells. Long runs that span multiple
503 /// grid cells are physically split. This catches:
504 /// - Lines and rules
505 /// - Touching character groups
506 /// - Decorative borders
507 ///
508 /// After reassignment, `make_ccs_from_ccids()` is called again to
509 /// recompute all CC descriptors.
510 pub fn merge_and_split_ccs(&mut self) {
511 if self.ccs.is_empty() {
512 return;
513 }
514
515 let splitsize = self.largesize;
516 let mut ncc = self.ccs.len() as i32;
517 let mut extra_runs: Vec<Run> = Vec::new();
518
519 // We need a way to map (gridi, gridj, ccid) → new ccid.
520 // Using a HashMap like DjVuLibre's GMap.
521 use std::collections::HashMap;
522 let mut grid_map: HashMap<(i16, i16, i32), i32> = HashMap::new();
523
524 self.nregularccs = self.ccs.len();
525
526 let makeccid =
527 |key: (i16, i16, i32), map: &mut HashMap<(i16, i16, i32), i32>, ncc: &mut i32| -> i32 {
528 if let Some(&id) = map.get(&key) {
529 id
530 } else {
531 let id = *ncc;
532 map.insert(key, id);
533 *ncc += 1;
534 id
535 }
536 };
537
538 for ccid in 0..self.ccs.len() {
539 let cc = &self.ccs[ccid];
540 if cc.nrun <= 0 {
541 continue;
542 }
543
544 let cc_height = cc.bb.height();
545 let cc_width = cc.bb.width();
546 let frun = cc.frun as usize;
547 let nrun = cc.nrun as usize;
548
549 if cc_height <= self.smallsize && cc_width <= self.smallsize {
550 // ── Merge small CC ───────────────────────────────────
551 // Map all runs to the same grid cell, with ccid = -1
552 // so that unrelated small CCs in the same cell merge.
553 let gridi = ((cc.bb.ymin + cc.bb.ymax) / splitsize / 2) as i16;
554 let gridj = ((cc.bb.xmin + cc.bb.xmax) / splitsize / 2) as i16;
555 let key = (gridi, gridj, -1);
556 let new_ccid = makeccid(key, &mut grid_map, &mut ncc);
557 for r in frun..frun + nrun {
558 if r < self.runs.len() {
559 self.runs[r].ccid = new_ccid;
560 }
561 }
562 } else if cc_height >= self.largesize || cc_width >= self.largesize {
563 // ── Split large CC ───────────────────────────────────
564 for r in frun..frun + nrun {
565 if r >= self.runs.len() {
566 continue;
567 }
568
569 let run_y = self.runs[r].y;
570 let run_x1 = self.runs[r].x1;
571 let run_x2 = self.runs[r].x2;
572
573 let gridi = (run_y / splitsize) as i16;
574 let gridj_start = (run_x1 / splitsize) as i16;
575 let gridj_end = (run_x2 / splitsize) as i16;
576
577 let key = (gridi, gridj_start, ccid as i32);
578 let new_ccid = makeccid(key, &mut grid_map, &mut ncc);
579 self.runs[r].ccid = new_ccid;
580
581 if gridj_end > gridj_start {
582 // Run spans multiple grid columns — split it.
583 // Truncate the original run to its first grid cell.
584 let orig_x2 = self.runs[r].x2;
585 self.runs[r].x2 = (gridj_start as i32 + 1) * splitsize - 1;
586
587 // Create new runs for intermediate grid cells
588 let mut current_gridj = gridj_start + 1;
589 while current_gridj < gridj_end {
590 let cell_x1 = current_gridj as i32 * splitsize;
591 let cell_x2 = cell_x1 + splitsize - 1;
592 let key = (gridi, current_gridj, ccid as i32);
593 let cell_ccid = makeccid(key, &mut grid_map, &mut ncc);
594 extra_runs.push(Run {
595 y: run_y,
596 x1: cell_x1,
597 x2: cell_x2,
598 ccid: cell_ccid,
599 });
600 current_gridj += 1;
601 }
602
603 // Create run for the last grid cell
604 let last_x1 = gridj_end as i32 * splitsize;
605 let key = (gridi, gridj_end, ccid as i32);
606 let last_ccid = makeccid(key, &mut grid_map, &mut ncc);
607 extra_runs.push(Run {
608 y: run_y,
609 x1: last_x1,
610 x2: orig_x2,
611 ccid: last_ccid,
612 });
613 }
614 }
615 }
616 // Normal-sized CCs keep their existing ccid — no changes needed.
617 }
618
619 // Append any extra runs that were created by splitting
620 self.runs.append(&mut extra_runs);
621
622 // Recompute all CC descriptors from the updated ccids
623 self.make_ccs_from_ccids();
624 }
625
626 // ── Reading-order sort ──────────────────────────────────────────────
627
628 /// Sort CCs in approximate reading order: top-to-bottom by text line,
629 /// left-to-right within each line.
630 ///
631 /// This is important for JB2 encoding efficiency because the encoder
632 /// uses relative positioning — nearby symbols in encoding order should
633 /// be spatially close. It also means the dictionary sees similar
634 /// characters (same font, same size) in sequence, improving
635 /// cross-coding compression.
636 ///
637 /// Direct port of `CCImage::sort_in_reading_order()`.
638 pub fn sort_in_reading_order(&mut self) {
639 let n = self.nregularccs;
640 if n < 2 {
641 return;
642 }
643
644 // Work on a copy of the regular CCs
645 let mut cc_arr: Vec<(usize, CC)> = self.ccs[..n]
646 .iter()
647 .enumerate()
648 .map(|(i, cc)| (i, cc.clone()))
649 .collect();
650
651 // Sort by top edge ascending (lowest ymin first) for Top-Down coordinates.
652 // This ensures Top-to-Bottom reading order.
653 cc_arr.sort_by(|a, b| {
654 a.1.bb
655 .ymin
656 .cmp(&b.1.bb.ymin)
657 .then(a.1.bb.xmin.cmp(&b.1.bb.xmin))
658 .then(a.1.frun.cmp(&b.1.frun))
659 });
660
661 // Determine max vertical deviation for line grouping
662 let maxtopchange = (self.width / 40).max(32);
663
664 // Group into text lines and sort within each line
665 let mut ccno = 0usize;
666 while ccno < n {
667 let line_start_ymin = cc_arr[ccno].1.bb.ymin;
668 // Scan for the end of this line (items that are vertically close)
669
670 let mut nccno = ccno + 1;
671 while nccno < n {
672 let curr_ymin = cc_arr[nccno].1.bb.ymin;
673
674 // If the next items top edge is significantly below the line start, it's a new line
675 if curr_ymin > line_start_ymin + maxtopchange {
676 break;
677 }
678 nccno += 1;
679 }
680
681 // Sort this line left-to-right (by xmin)
682 cc_arr[ccno..nccno].sort_by(|a, b| a.1.bb.xmin.cmp(&b.1.bb.xmin));
683
684 // Move to next line
685 ccno = nccno;
686 }
687
688 // Write back and relabel runs
689 let mut new_ccs = Vec::with_capacity(self.ccs.len());
690 let mut old_to_new = vec![0usize; self.ccs.len()];
691
692 for (new_idx, (old_idx, cc)) in cc_arr.into_iter().enumerate() {
693 new_ccs.push(cc);
694 old_to_new[old_idx] = new_idx;
695 }
696
697 // Append the non-regular CCs
698 for i in n..self.ccs.len() {
699 let new_idx = new_ccs.len();
700 new_ccs.push(self.ccs[i].clone());
701 old_to_new[i] = new_idx;
702 }
703
704 self.ccs = new_ccs;
705
706 // Remap runs
707 for run in &mut self.runs {
708 if run.ccid >= 0 {
709 run.ccid = old_to_new[run.ccid as usize] as i32;
710 }
711 }
712 }
713
714 // ── Bitmap extraction ───────────────────────────────────────────────
715
716 /// Extract a bitmap for a single CC by painting its runs into a fresh
717 /// `BitImage`.
718 pub fn get_bitmap_for_cc(&self, ccid: usize) -> Option<BitImage> {
719 if ccid >= self.ccs.len() {
720 return None;
721 }
722 let cc = &self.ccs[ccid];
723 let bb = &cc.bb;
724 let w = bb.width();
725 let h = bb.height();
726 if w <= 0 || h <= 0 {
727 return None;
728 }
729
730 let mut bm = BitImage::new(w as u32, h as u32).ok()?;
731 let frun = cc.frun as usize;
732 let nrun = cc.nrun as usize;
733
734 for i in frun..frun + nrun {
735 if i >= self.runs.len() {
736 break;
737 }
738 let run = &self.runs[i];
739 let row = run.y - bb.ymin;
740 for x in run.x1..=run.x2 {
741 let col = x - bb.xmin;
742 bm.set_usize(col as usize, row as usize, true);
743 }
744 }
745
746 Some(bm)
747 }
748
749 // ── High-level pipeline ─────────────────────────────────────────────
750
751 /// Run the full CC analysis pipeline:
752 ///
753 /// 1. `make_ccids_by_analysis()` — union-find labeling
754 /// 2. `make_ccs_from_ccids()` — build descriptors
755 /// 3. `sort_in_reading_order()` — reading-order sort
756 ///
757 /// After this, iterate `0..self.ccs.len()` and call
758 /// `get_bitmap_for_cc(i)` to extract symbol bitmaps.
759 pub fn analyze(&mut self, losslevel: i32) {
760 let _ = losslevel;
761 self.make_ccids_by_analysis();
762 self.make_ccs_from_ccids();
763 self.sort_in_reading_order();
764 }
765
766 /// Return lightweight component descriptors without allocating bitmaps.
767 pub fn extract_shape_refs(&self) -> Vec<ShapeRef> {
768 let mut shapes = Vec::with_capacity(self.ccs.len());
769 for (ccid, cc) in self.ccs.iter().enumerate() {
770 if cc.nrun <= 0 {
771 continue;
772 }
773 shapes.push(ShapeRef {
774 ccid,
775 bbox: cc.bb,
776 black_pixels: cc.npix.max(0) as usize,
777 run_count: cc.nrun.max(0) as usize,
778 });
779 }
780 shapes
781 }
782
783 /// Convert the analyzed CCs into (bitmap, bounding_box) pairs ready
784 /// for JB2 encoding, filtering out empty results.
785 pub fn extract_shapes(&self) -> Vec<(BitImage, BBox)> {
786 let mut shapes = Vec::with_capacity(self.ccs.len());
787 for shape in self.extract_shape_refs() {
788 if let Some(bm) = self.get_bitmap_for_cc(shape.ccid) {
789 shapes.push((bm, shape.bbox));
790 }
791 }
792 shapes
793 }
794}
795
796// ─── Convenience entry point ────────────────────────────────────────────────
797
798/// Perform connected-component analysis on a `BitImage` and return the
799/// extracted shapes with their bounding boxes, ready for JB2 encoding.
800///
801/// This replaces the Lutz-based `find_connected_components()` and the
802/// entire `extract_symbols()` pipeline from `jbig2lutz.rs`.
803///
804/// ## Parameters
805/// - `image`: the full-page bilevel image
806/// - `dpi`: image resolution (typically 300 for scanned documents)
807/// - `losslevel`: 0 = lossless (no cleaning), >0 enables noise removal
808///
809/// ## Returns
810/// A `CCImage` with the full analysis complete. Call `extract_shapes()`
811/// to get `(BitImage, BBox)` pairs.
812pub fn analyze_page(image: &BitImage, dpi: i32, losslevel: i32) -> CCImage {
813 let mut ccimg = CCImage::new(image.width as i32, image.height as i32, dpi);
814 ccimg.add_bitmap_runs(image);
815 ccimg.analyze(losslevel);
816 ccimg
817}
818
819/// Extract symbols suitable for JBIG2 symbol dictionary creation.
820///
821/// Returns a Vec of (bitmap, x, y, width, height) where x,y is the position
822/// on the page and width,height are dimensions.
823///
824/// This format can be converted to the structures expected by the JBIG2
825/// symbol matching and encoding pipeline.
826pub fn extract_symbols_for_jbig2(cc_image: &CCImage) -> Vec<(BitImage, i32, i32, i32, i32)> {
827 let shapes = cc_image.extract_shapes();
828 shapes
829 .into_iter()
830 .map(|(bitmap, bbox)| (bitmap, bbox.xmin, bbox.ymin, bbox.width(), bbox.height()))
831 .collect()
832}
833
834#[cfg(test)]
835mod tests {
836 use super::*;
837
838 fn make_test_image() -> BitImage {
839 // Create a small test image with two separate blobs
840 let mut bm = BitImage::new(40, 20).unwrap();
841 // Blob 1: 5x5 at (2, 2)
842 for y in 2..7 {
843 for x in 2..7 {
844 bm.set_usize(x, y, true);
845 }
846 }
847 // Blob 2: 5x5 at (20, 10)
848 for y in 10..15 {
849 for x in 20..25 {
850 bm.set_usize(x, y, true);
851 }
852 }
853 bm
854 }
855
856 #[test]
857 fn test_run_extraction() {
858 let bm = make_test_image();
859 let mut ccimg = CCImage::new(40, 20, 300);
860 ccimg.add_bitmap_runs(&bm);
861 // Each blob has 5 rows, each row is one run → 10 runs total
862 assert_eq!(ccimg.runs.len(), 10);
863 }
864
865 #[test]
866 fn test_cc_analysis_finds_two_components() {
867 let bm = make_test_image();
868 let mut ccimg = CCImage::new(40, 20, 300);
869 ccimg.add_bitmap_runs(&bm);
870 ccimg.make_ccids_by_analysis();
871 ccimg.make_ccs_from_ccids();
872
873 assert_eq!(ccimg.ccs.len(), 2);
874 assert_eq!(ccimg.ccs[0].npix, 25);
875 assert_eq!(ccimg.ccs[1].npix, 25);
876 }
877
878 #[test]
879 fn test_full_pipeline() {
880 let bm = make_test_image();
881 let ccimg = analyze_page(&bm, 300, 0);
882 let shapes = ccimg.extract_shapes();
883
884 assert_eq!(shapes.len(), 2);
885 for (bitmap, bb) in &shapes {
886 assert_eq!(bitmap.width, 5);
887 assert_eq!(bitmap.height, 5);
888 assert_eq!(bb.width(), 5);
889 assert_eq!(bb.height(), 5);
890 }
891 }
892
893 #[test]
894 fn test_tiny_cc_removal() {
895 let mut bm = BitImage::new(40, 20).unwrap();
896 // One real blob
897 for y in 2..7 {
898 for x in 2..7 {
899 bm.set_usize(x, y, true);
900 }
901 }
902 // One tiny speck (1 pixel)
903 bm.set_usize(30, 10, true);
904
905 let ccimg = analyze_page(&bm, 300, 1); // losslevel > 0 enables cleaning
906 let shapes = ccimg.extract_shapes();
907
908 // The speck should have been removed (tinysize at 300 DPI = 3)
909 assert_eq!(shapes.len(), 1);
910 assert_eq!(shapes[0].0.width, 5);
911 }
912}