M10: Enhance camera image cleans the photo up and chooses its look (part 4 of #1008, closes #1034) (#1038)

* M10: enhance camera images: straighten a page from four corners (ocr.camera-perspective)

Four corners on a page (any order, a convex quadrilateral) are warped to an
upright rectangle that replaces what the page shows, read at the page
picture's own resolution (at most 600 dpi) and kept at that physical size.
One undoable step through Replace Pages, so comments, links and bookmarks
stay.

- geom: a pure 3x3 homography solve and corner ordering (no dependencies);
  collinear, concave and non-finite input gives None.
- engine: camera::warp (bilinear, over white, capped at 40 MP) and
  Session::enhance_camera_page.
- automation: page_enhance_camera (corners in top-left-origin points).
- parity: ocr.camera-perspective is partial; the UI handles, automatic edge
  detection and contrast cleanup are still missing.

Refs #1008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Enhance camera image: drag four corners on the page, then Enhance page (ocr.camera-perspective)

Scan & OCR ▸ Enhance camera image (ocr.enhance_camera) shows four corner
handles on the current page, inset 5% from its edges, with what will be cut
away dimmed. Enhance page straightens what they enclose through
Session::enhance_camera_page (one undoable step); Cancel and Esc leave the
page alone. The buttons float at the top right, clear of a centred page's
corners, and the instructions are a notice, as for Crop.

A command of its own, beside Enhance scanned file (ocr.enhance), which
stays for scan correction (#1012).

- ui-egui: camera_ui (handles, dimming, bar), QuickTool::EnhanceCamera,
  quick_tool "enhance-camera" for ui.state and the shot example.
- Catalogs: the three new labels in every language with UI coverage
  (plus es, pl, zh-hant); ATTRIBUTION.toml hashes updated.
- automation: page_enhance_camera is linked to the command.
- parity: ocr.camera-perspective lists the command and the UI test.

Refs #1008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Enhance camera image finds the document's edges (ocr.camera-perspective)

The corners now start on the photographed document's edges when they can
be found, else inset 5% as before.

camera_edges::detect_document, pure Rust and deterministic: shrink to 400
px, blur, Sobel edges thinned and thresholded against the 98th percentile;
a Hough transform where each edge votes within 6 degrees of its own
normal; pairs of near-horizontal and near-vertical lines make convex
quadrilaterals (at least 12% of the picture), kept when every side lies on
a matching edge along at least 55% of its length and scored by area times
the square of the mean support; each side is then refitted through its
edge points (total least squares). None when nothing convinces.

- Session::detect_camera_corners (user space); page_enhance_camera finds
  the corners itself when `corners` is omitted and reports the corners it
  used and whether they were detected; the UI command starts from them.
- Tests on synthetic photos: plain table, text on the sheet, wood grain
  with a shadow, strong perspective, a 30 degree turn, a sheet running off
  the picture, no document, white on white, bad input; end to end through
  the engine and the tool.
- Parity notes: what is still missing (turning the result upright, rounded
  corners, clean-up).

Refs #1008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: after Enhance page, rotate, crop or organize the page (ocr.camera-perspective)

Enhance camera image no longer closes on Enhance page: the bar then offers
rotate counterclockwise and clockwise for the enhanced page (one undo step
each), Crop pages and Organize pages (which leave the tool for those
tools), and Done; Esc leaves too. A photo of a card often comes out a
quarter turn off until the page is turned upright automatically.

The two icon buttons are named for screen readers. Every label already
exists in the catalogs.

Refs #1008.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* M10: Enhance camera image cleans the photo up and chooses its look (#1034)

After Enhance page the bar offers a look (Original, Colour, Greyscale,
Black and white) and a Clean-up slider (0-100, default Colour at 50).
Choosing a look or letting go of the slider enhances the page again from
the straightened photo and turns it as before, so the history keeps one
enhancement (and one turn), never a pile of them.

camera_clean, pure Rust:
- clean: the paper's illumination is estimated per channel on a coarse
  grid (each block's 90th percentile, lifted to the 75th percentile of the
  5x5 blocks around it so a stamp or picture isn't taken for paper, then
  smoothed) and divided out, so casts, shadows and darker edges go and the
  paper turns white; a contrast stretch grows with the level, and from 60
  isolated dark pixels are removed. Colour ink stays.
- apply(look): Original is the photo as taken; Greyscale is the cleaned
  page in grey; Black and white evens the lighting fully, thresholds the
  whole page (the level sets the threshold: higher keeps fainter strokes)
  and despeckles. Black and white is stored as PNG (lossless): JPEG would
  smear grey around every letter.

- Session::enhance_camera_page_at(look, level); page_enhance_camera takes
  `mode` (original, colour, greyscale, bw) and `level` (0-100), refusing
  anything else.
- "Clean-up", "Original", "Greyscale" and "Black and white" in every
  catalog with UI coverage, plus es, pl and zh-hant.
- Tests: shadow and yellow cast become white paper with dark text, a blue
  stamp stays blue, strength grows with the level, specks go but text
  stays, the four looks (black and white is only black and white, the
  threshold moves with the level), bad sizes; a shadowed sheet through the
  engine; level and mode through the tool; the slider and the looks
  re-enhancing from the photo and keeping the turn.
- Parity: ocr.background-removal, ocr.edge-shadow-removal and
  ocr.despeckle are partial (camera images only).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Camera level: when another edit broke the run, redo what was undone instead of warping the page again

---------

Co-authored-by: magdale76 <16279778+magdale76@users.noreply.github.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: Brandon Thomas <echelon@gmail.com>
This commit is contained in:
magdale76
2026-10-11 14:57:31 -04:00
committed by GitHub
co-authored by Claude Opus 5.5 magdale76 Brandon Thomas
parent f036d3841b
commit 8f8e0236aa
28 changed files with 801 additions and 53 deletions
+15 -15
View File
@@ -19,7 +19,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Simplified Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "fd1660ea192c41ec38d11344035a5d2a5f453479c43a1f0bb56b65919fa5073d"
sha256 = "e14fad9deb20599267c75e53b3657913f04b349b8e453ee6ee364b5fbb3b6386"
[[asset]]
path = "crates/ui-egui/src/i18n/zh-hant.tsv"
@@ -30,7 +30,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Traditional Chinese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "72d037149b24ad84a83cc7f0a7d27b1ecae93451d4cdc19ed861899789abbb74"
sha256 = "6b236b954c6808da38585ffa4403d5c4654bd254d596c4d0c82963611f9ed74a"
[[asset]]
path = "crates/ui-egui/src/i18n/cs.tsv"
@@ -52,7 +52,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Brazilian Portuguese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7d9b83a8f699b37e191849d79e0f116dff45486d8fccef50bd4b20c8172f95b0"
sha256 = "3da0ebbb21fd15393184a8f3ae824b6776e52c3318b0623430ae7a3d56f2234a"
[[asset]]
path = "crates/ui-egui/src/i18n/es.tsv"
@@ -63,7 +63,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Spanish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "12c812d37a92633a81f4dab6da7808cb6c55eb7bb6fa318f0c79a58ede065bce"
sha256 = "e7f44217b71746ce6a0b9349a61752594508208e5aa1633417a492cba73b3f8d"
[[asset]]
path = "crates/ui-egui/src/i18n/fr.tsv"
@@ -74,7 +74,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "French UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "111430ed0180b24a65f6d2b76e6bbc51b88d11c81f0da0e6d0975744584f7ad4"
sha256 = "8de3262b73b7b581e9db7d3c03305675431f4f9e3845eef04f4fd43ac65201ff"
[[asset]]
path = "crates/ui-egui/src/i18n/de.tsv"
@@ -85,7 +85,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "German UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "cf5866438a696013303886a4a60de3348f5acb5acc4f97ec6889f5759c785a04"
sha256 = "9860be07e4cef530e85e97d0f160201e1f1f510af3aaefb322aa1c8a9c81af6b"
[[asset]]
path = "crates/ui-egui/src/i18n/te.tsv"
@@ -96,7 +96,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Telugu UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "0c52c43e61ebc4d9486ee699fb442442ed4813a0f9cf365a48e2029cfe7cb950"
sha256 = "1ee6f975773e1cc628b8552d5e079d9e465e4935c781c95488355ae1b2a6cd96"
[[asset]]
path = "crates/ui-egui/src/i18n/it.tsv"
@@ -107,7 +107,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Italian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "5564af1f42d201c1d2e2d14a77c50eff19c254f5ef9e03165552c16f6806a35b"
sha256 = "d0110ca54b7de2667939d5e3e9f235364e317a2eb3b5046f7c934fcb686c3980"
[[asset]]
path = "crates/ui-egui/src/i18n/pl.tsv"
@@ -118,7 +118,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Polish UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "4be664cc867d6b2a8cb74a87a5163530249de36a46b39115ff071f2f4402b1cb"
sha256 = "86f7835d14675695f30fc5aa1037d2a949db25f4635df4a9d8f78e2a1b89fe97"
[[asset]]
path = "crates/ui-egui/src/i18n/ru.tsv"
@@ -129,7 +129,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Russian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "f1d9178f5c73fff154c7408c01ee26a12cb0c76b614a4e16e0dd6a27b6bacb4e"
sha256 = "fbb4b86a4de843f9f3a66a1b23423694e5615f0581ccb8ef309d567f34d573a4"
[[asset]]
path = "crates/ui-egui/src/i18n/hu.tsv"
@@ -140,7 +140,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Hungarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "00473565c3545c1fe86aff596c57e004eb25ae914d831f628b57ec08113f03d8"
sha256 = "590e5b3b943f529bbdfb3ee36bd11de053f9ada40b3ec0fc3528ad552b1c7a7a"
[[asset]]
path = "crates/ui-egui/src/i18n/bg.tsv"
@@ -151,7 +151,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Bulgarian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "a38c9276670bfd292358d021ef55d12b17f944f9236180a068d55f59d454957d"
sha256 = "112f2cadebcadad7cd80d99fdad0d9ed92ce88f6aaedb5c4388b91eb86e5afb7"
[[asset]]
path = "crates/ui-egui/src/i18n/ja.tsv"
@@ -162,7 +162,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Japanese UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "c6391eb0e85439a8473eb152ab58e817831e8ad48ec6c4a54315126cf4a46ba2"
sha256 = "c7ab8fdfa62aacf315f1b75007ef04bf6aca31401f9d33cb28ffb8f47dede5ed"
[[asset]]
path = "crates/ui-egui/src/i18n/uk.tsv"
@@ -173,7 +173,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Ukrainian UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "1d2f66915cde08456309a2ca726257e6b025e56bcc49377d7cd3fc095d294a2f"
sha256 = "f4b9277ffc916dd12ff2c7982c0a54e205a797cbecdaa3e44120644598330094"
[[asset]]
path = "crates/ui-egui/src/i18n/ar.tsv"
@@ -184,7 +184,7 @@ licence = "MIT OR Apache-2.0"
licence_file = "LICENSE-MIT"
kind = "translation"
usage = "Arabic UI strings, compiled in by crates/ui-egui/src/i18n/mod.rs"
sha256 = "7551eba21c7bfdba960684711e1580c32059f5620a02903898441058d1b93ed3"
sha256 = "9e57598e8b9ca7b776e85a257b0813435babe175f8c185361e8d016c11a0d521"
[[asset]]
path = "assets/icons/accessibility.svg"
+11 -1
View File
@@ -221,7 +221,17 @@ impl Automation {
failed("the document's edges could not be found on this page: pass `corners` [x1, y1, x2, y2, x3, y3, x4, y4]")
})?,
};
self.session.enhance_camera_page(id, page, corners).map_err(failed)?;
let level = match a.opt_int("level")? {
None => 0,
Some(l @ 0..=100) => l as u8,
Some(l) => return Err(ToolError::InvalidArgs(format!("level must be between 0 and 100, not {l}"))),
};
let look = match a.opt_str("mode")? {
None => pdfcraft_engine::camera_clean::Look::Colour,
Some(m) => pdfcraft_engine::camera_clean::Look::from_name(m)
.ok_or_else(|| ToolError::InvalidArgs(format!("unknown mode {m:?} (original, colour, greyscale, bw)")))?,
};
self.session.enhance_camera_page_at(id, page, corners, look, level).map_err(failed)?;
let r = |v: f32| (v * 100.0).round() / 100.0;
let used: Vec<f32> = corners.iter().flat_map(|&[x, y]| info.user_to_view(x as f32, y as f32)).map(r).collect();
let mut out = summary(self.doc(&a)?);
+3 -1
View File
@@ -184,13 +184,15 @@ pub fn tools() -> Vec<ToolDef> {
}),
&["doc", "degrees"],
)),
t("page_enhance_camera", "Enhance camera image", "Straighten a photographed document (Scan & OCR ▸ Enhance ▸ Camera image): the part of the page inside the four corners (x1, y1, … x4, y4 in top-left-origin points, any order, a convex quadrilateral; when omitted, the document's edges are found automatically, or an error says they could not be) is warped to an upright rectangle that replaces what the page shows, at the page picture's own resolution (at most 600 dpi). The page's comments, links and bookmarks stay. Undoable.")
t("page_enhance_camera", "Enhance camera image", "Straighten a photographed document (Scan & OCR ▸ Enhance ▸ Camera image): the part of the page inside the four corners (x1, y1, … x4, y4 in top-left-origin points, any order, a convex quadrilateral; when omitted, the document's edges are found automatically, or an error says they could not be) is warped to an upright rectangle that replaces what the page shows, at the page picture's own resolution (at most 600 dpi). mode is how it looks: original, colour (default), greyscale or bw (black and white, stored losslessly). level (0–100, default 0) then cleans the photo up: even lighting so the paper turns white (casts, shadows, dark edges), more contrast, and from 60 no isolated specks; colour ink stays colour. For bw the level is the threshold (higher keeps fainter strokes black). The page's comments, links and bookmarks stay. Undoable.")
.cmd("ocr.enhance_camera")
.with(schema(
json!({
"doc": doc(),
"page": { "type": "integer", "minimum": 1 },
"corners": { "type": "array", "items": { "type": "number" }, "minItems": 8, "maxItems": 8 },
"level": { "type": "integer", "minimum": 0, "maximum": 100 },
"mode": { "type": "string", "enum": ["original", "colour", "greyscale", "bw"] },
}),
&["doc", "page"],
)),
+4
View File
@@ -1728,6 +1728,10 @@ fn enhance_camera_image_finds_the_edges_by_itself() {
let r = ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1 }));
assert_eq!(r["detected"], true);
assert_eq!(r["undo"], "Enhance camera image");
// A clean-up level outside 0–100 is refused before anything changes.
let e = a.call("page_enhance_camera", &json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 101 }));
assert!(matches!(&e, Err(ToolError::InvalidArgs(m)) if m.contains("level")), "{e:?}");
ok(&mut a, "page_enhance_camera", json!({ "doc": photo, "page": 1, "corners": [0, 0, 100, 0, 100, 100, 0, 100], "level": 80 }));
// One point per pixel: the corners it used are the sheet's.
let used: Vec<f64> = r["corners"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect();
for (k, p) in sheet.iter().enumerate() {
+52 -10
View File
@@ -115,6 +115,20 @@ impl Session {
/// inside `corners` (PDF user space, any order), straightened to a rectangle. The page is
/// read at its picture's own resolution (at most 600 dpi) and keeps that physical size.
pub fn enhance_camera_page(&mut self, id: DocId, page: usize, corners: [Point; 4]) -> Result<(), EditError> {
self.enhance_camera_page_at(id, page, corners, crate::camera_clean::Look::Original, 0)
}
/// [`Session::enhance_camera_page`], then give the straightened photo a `look` at `level`
/// (0 = as photographed … 100 = white paper, strong contrast, no specks; for black and white
/// the threshold; see [`crate::camera_clean`]). Still one undoable step.
pub fn enhance_camera_page_at(
&mut self,
id: DocId,
page: usize,
corners: [Point; 4],
look: crate::camera_clean::Look,
level: u8,
) -> Result<(), EditError> {
let doc = self.get(id).ok_or(EditError::NoDocument)?;
if let Some(why) = &doc.read_only_reason {
return Err(EditError::ReadOnly(why.clone()));
@@ -147,18 +161,46 @@ impl Session {
[vx as f64 * k, vy as f64 * k]
});
let out = warp(&shot.rgba, shot.width, shot.height, quad).map_err(EditError::Invalid)?;
let mut jpeg = Vec::new();
image::ImageEncoder::write_image(
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut jpeg, QUALITY),
&out.rgb,
out.width,
out.height,
image::ExtendedColorType::Rgb8,
)
.map_err(|e| EditError::Invalid(format!("the straightened page could not be encoded: {e}")))?;
let pixels = crate::camera_clean::apply(&out.rgb, out.width, out.height, look, level)
.ok_or_else(|| EditError::Invalid("the straightened page could not be cleaned up".into()))?;
// Black and white is lossless (PNG): JPEG would smear grey around every letter.
let mut encoded = Vec::new();
let (name, written) = match &pixels {
crate::camera_clean::Pixels::Rgb(rgb) => (
"camera.jpg",
image::ImageEncoder::write_image(
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, QUALITY),
rgb,
out.width,
out.height,
image::ExtendedColorType::Rgb8,
),
),
crate::camera_clean::Pixels::Grey(grey) if look == crate::camera_clean::Look::BlackWhite => (
"camera.png",
image::ImageEncoder::write_image(
image::codecs::png::PngEncoder::new(&mut encoded),
grey,
out.width,
out.height,
image::ExtendedColorType::L8,
),
),
crate::camera_clean::Pixels::Grey(grey) => (
"camera.jpg",
image::ImageEncoder::write_image(
image::codecs::jpeg::JpegEncoder::new_with_quality(&mut encoded, QUALITY),
grey,
out.width,
out.height,
image::ExtendedColorType::L8,
),
),
};
written.map_err(|e| EditError::Invalid(format!("the straightened page could not be encoded: {e}")))?;
// One device pixel per `k` points keeps the page's physical size.
let dpi = (k * 72.0).clamp(1.0, 1200.0);
let bytes = self.create_from_images_with_resolution(&[("camera.jpg".into(), jpeg)], ImageResolution::Dpi(dpi))?;
let bytes = self.create_from_images_with_resolution(&[(name.into(), encoded)], ImageResolution::Dpi(dpi))?;
let replace = Edit::ReplacePages { pages: vec![page], name: "camera.pdf".into(), bytes, src_pages: vec![0] };
self.apply(id, Edit::Batch { label: "Enhance camera image".into(), edits: vec![replace] })
}
+392
View File
@@ -0,0 +1,392 @@
//! Clean up a straightened camera photo (Enhance ▸ Camera image ▸ enhancement level): even out
//! the lighting so the paper turns white (room-light colour casts, a hand's shadow, darker
//! edges go with it), then raise the contrast and drop isolated specks. Colour ink stays colour.
//!
//! The paper's illumination is estimated per channel on a coarse grid of blocks, from each
//! block's bright end (the paper, not the ink), smoothed into a continuous map and divided out.
//! Pure Rust and deterministic.
/// The strongest a level can be.
pub const MAX_LEVEL: u8 = 100;
/// How an enhanced camera page looks.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub enum Look {
/// The straightened photo as it was taken.
Original,
/// Cleaned up, colour ink kept.
#[default]
Colour,
/// Cleaned up, in shades of grey.
Greyscale,
/// Black ink on white paper only, each pixel one or the other.
BlackWhite,
}
impl Look {
pub const ALL: [Look; 4] = [Look::Original, Look::Colour, Look::Greyscale, Look::BlackWhite];
/// The name tools use.
pub fn name(self) -> &'static str {
match self {
Look::Original => "original",
Look::Colour => "colour",
Look::Greyscale => "greyscale",
Look::BlackWhite => "bw",
}
}
pub fn from_name(name: &str) -> Option<Look> {
Look::ALL.into_iter().find(|l| l.name() == name)
}
}
/// A page's pixels after [`apply`]: RGB8, or one grey byte per pixel.
#[derive(Clone, Debug, PartialEq)]
pub enum Pixels {
Rgb(Vec<u8>),
Grey(Vec<u8>),
}
/// Give a straightened photo (`rgb`, RGB8) the `look` at `level`. For black and white the level
/// sets the threshold: higher keeps fainter strokes black. `None` when the buffer doesn't match
/// the size.
pub fn apply(rgb: &[u8], width: u32, height: u32, look: Look, level: u8) -> Option<Pixels> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
return None;
}
let lum = |p: &[u8; 3]| ((p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000) as u8;
Some(match look {
Look::Original => Pixels::Rgb(rgb.to_vec()),
Look::Colour => Pixels::Rgb(clean(rgb, width, height, level)?),
Look::Greyscale => Pixels::Grey(clean(rgb, width, height, level)?.as_chunks::<3>().0.iter().map(lum).collect()),
Look::BlackWhite => {
// The lighting evened out fully, then one threshold for the whole page.
let even = clean(rgb, width, height, 50)?;
let t = 0.45 + 0.4 * level.min(MAX_LEVEL) as f32 / MAX_LEVEL as f32;
let mut grey: Vec<u8> = even.as_chunks::<3>().0.iter().map(|p| if (lum(p) as f32 / 255.0) < t { 0 } else { 255 }).collect();
despeckle_grey(&mut grey, w, h);
Pixels::Grey(grey)
}
})
}
/// Clean `rgb` (RGB8, `width` × `height`) at `level` (0 = unchanged … 100 = strongest).
/// Returns `None` when the buffer doesn't match the size.
pub fn clean(rgb: &[u8], width: u32, height: u32, level: u8) -> Option<Vec<u8>> {
let (w, h) = (width as usize, height as usize);
if w == 0 || h == 0 || w.checked_mul(h)?.checked_mul(3)? != rgb.len() {
return None;
}
let level = level.min(MAX_LEVEL);
if level == 0 {
return Some(rgb.to_vec());
}
let k = level as f32 / MAX_LEVEL as f32;
let background = background(rgb, w, h);
// Lighting is evened out fully from half strength on; contrast keeps growing to the top.
let flatten = (2.0 * k).min(1.0);
let white = 1.0 - 0.12 * k; // what reaches white
let black = 0.30 * k; // what reaches black
let mut out = Vec::with_capacity(rgb.len());
for (i, px) in rgb.as_chunks::<3>().0.iter().enumerate() {
let (x, y) = (i % w, i / w);
let bg = background.at(x, y);
for (c, &v) in px.iter().enumerate() {
let v = v as f32 / 255.0;
let b = bg.get(c).copied().unwrap_or(1.0).max(0.05);
let even = (v / b).min(1.0);
let flat = v + (even - v) * flatten;
let stretched = ((flat - black) / (white - black).max(0.05)).clamp(0.0, 1.0);
out.push((stretched * 255.0).round() as u8);
}
}
if level >= 60 {
despeckle(&mut out, w, h);
}
Some(out)
}
/// The paper's illumination: a grid of per-channel values, read back with bilinear
/// interpolation.
struct Background {
cols: usize,
rows: usize,
block: f32,
cells: Vec<[f32; 3]>,
}
impl Background {
fn cell(&self, cx: isize, cy: isize) -> [f32; 3] {
let cx = cx.clamp(0, self.cols as isize - 1) as usize;
let cy = cy.clamp(0, self.rows as isize - 1) as usize;
self.cells.get(cy * self.cols + cx).copied().unwrap_or([1.0; 3])
}
fn at(&self, x: usize, y: usize) -> [f32; 3] {
// Cell centres sit at (i + 0.5) × block.
let fx = (x as f32 + 0.5) / self.block - 0.5;
let fy = (y as f32 + 0.5) / self.block - 0.5;
let (x0, y0) = (fx.floor(), fy.floor());
let (ax, ay) = (fx - x0, fy - y0);
let (x0, y0) = (x0 as isize, y0 as isize);
let (a, b, c, d) = (self.cell(x0, y0), self.cell(x0 + 1, y0), self.cell(x0, y0 + 1), self.cell(x0 + 1, y0 + 1));
std::array::from_fn(|i| {
let top = a[i] * (1.0 - ax) + b[i] * ax;
let bottom = c[i] * (1.0 - ax) + d[i] * ax;
top * (1.0 - ay) + bottom * ay
})
}
}
/// Estimate the paper's illumination: per block, the 90th percentile of each channel (bright
/// paper, not dark ink); blocks that are mostly ink borrow from their neighbours; then smoothed.
fn background(rgb: &[u8], w: usize, h: usize) -> Background {
let block = (w.min(h) / 16).clamp(8, 128);
let (cols, rows) = (w.div_ceil(block), h.div_ceil(block));
let mut cells = Vec::with_capacity(cols * rows);
let mut hist = [[0u32; 256]; 3];
for by in 0..rows {
for bx in 0..cols {
hist.iter_mut().for_each(|hh| hh.fill(0));
let mut n = 0u32;
for y in by * block..((by + 1) * block).min(h) {
for x in bx * block..((bx + 1) * block).min(w) {
if let Some(p) = rgb.get((y * w + x) * 3..(y * w + x) * 3 + 3) {
for (c, &v) in p.iter().enumerate() {
if let Some(slot) = hist.get_mut(c).and_then(|hh| hh.get_mut(v as usize)) {
*slot += 1;
}
}
n += 1;
}
}
}
let want = (n as f32 * 0.9) as u32;
cells.push(std::array::from_fn(|c| {
let mut seen = 0;
let v = hist.get(c).and_then(|hh| {
hh.iter().position(|&count| {
seen += count;
seen > want
})
});
v.unwrap_or(255) as f32 / 255.0
}));
}
}
// A block darker than its neighbourhood's paper is mostly ink, a stamp or a picture: take
// the brighter of itself and the 75th percentile of the 5 × 5 blocks around it (so a
// coloured area up to about half that neighbourhood isn't taken for paper), then smooth.
let get = |cells: &Vec<[f32; 3]>, x: isize, y: isize| -> [f32; 3] {
let x = x.clamp(0, cols as isize - 1) as usize;
let y = y.clamp(0, rows as isize - 1) as usize;
cells.get(y * cols + x).copied().unwrap_or([1.0; 3])
};
let mut lifted = Vec::with_capacity(cells.len());
for y in 0..rows as isize {
for x in 0..cols as isize {
let own = get(&cells, x, y);
lifted.push(std::array::from_fn(|c| {
let mut around: Vec<f32> =
(-2..=2).flat_map(|dy| (-2..=2).map(move |dx| (dx, dy))).map(|(dx, dy)| get(&cells, x + dx, y + dy)[c]).collect();
around.sort_by(f32::total_cmp);
let paper = around.get(18).copied().unwrap_or(1.0);
own[c].max(paper)
}));
}
}
let mut cells = lifted;
for _ in 0..2 {
let mut next = Vec::with_capacity(cells.len());
for y in 0..rows as isize {
for x in 0..cols as isize {
let mut sum = [0.0f32; 3];
for dy in -1..=1 {
for dx in -1..=1 {
let v = get(&cells, x + dx, y + dy);
for c in 0..3 {
sum[c] += v[c] / 9.0;
}
}
}
next.push(sum);
}
}
cells = next;
}
Background { cols, rows, block: block as f32, cells }
}
/// [`despeckle`] for one grey byte per pixel.
fn despeckle_grey(grey: &mut [u8], w: usize, h: usize) {
let mut rgb: Vec<u8> = grey.iter().flat_map(|&v| [v, v, v]).collect();
despeckle(&mut rgb, w, h);
for (g, p) in grey.iter_mut().zip(rgb.as_chunks::<3>().0.iter()) {
*g = p[0];
}
}
/// Lighten dark pixels with at most one dark neighbour (specks), in place.
fn despeckle(rgb: &mut [u8], w: usize, h: usize) {
let lum = |p: &[u8; 3]| (p[0] as u32 * 299 + p[1] as u32 * 587 + p[2] as u32 * 114) / 1000;
let dark: Vec<bool> = rgb.as_chunks::<3>().0.iter().map(|p| lum(p) < 128).collect();
let is_dark = |x: isize, y: isize| {
x >= 0 && y >= 0 && (x as usize) < w && (y as usize) < h && dark.get(y as usize * w + x as usize).copied().unwrap_or(false)
};
for y in 0..h as isize {
for x in 0..w as isize {
if !is_dark(x, y) {
continue;
}
let neighbours =
(-1..=1).flat_map(|dy| (-1..=1).map(move |dx| (dx, dy))).filter(|&(dx, dy)| (dx, dy) != (0, 0) && is_dark(x + dx, y + dy)).count();
if neighbours <= 1
&& let Some(p) = rgb.get_mut((y as usize * w + x as usize) * 3..(y as usize * w + x as usize) * 3 + 3)
{
p.fill(255);
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
/// A synthetic photo of a page: paper lit by `light(x, y)` (per channel, 0–1), with dark
/// text bars, a blue stamp and a few specks.
fn photo(w: usize, h: usize, light: impl Fn(usize, usize) -> [f32; 3]) -> Vec<u8> {
let mut out = Vec::with_capacity(w * h * 3);
for y in 0..h {
for x in 0..w {
let text = (y / 12) % 3 == 1 && x > w / 10 && x < 9 * w / 10 && (x / 9) % 6 != 0;
let stamp = (x as isize - (3 * w / 4) as isize).pow(2) + (y as isize - (3 * h / 4) as isize).pow(2) < (h as isize / 12).pow(2);
let speck = (x * 7919 + y * 104729) % 997 == 0;
let ink: [f32; 3] = if text || speck {
[0.12, 0.12, 0.12]
} else if stamp {
[0.15, 0.25, 0.75]
} else {
[0.97, 0.97, 0.95]
};
let l = light(x, y);
for c in 0..3 {
out.push((ink[c] * l[c] * 255.0).round().clamp(0.0, 255.0) as u8);
}
}
}
out
}
fn px(img: &[u8], w: usize, x: usize, y: usize) -> [u8; 3] {
let i = (y * w + x) * 3;
[img[i], img[i + 1], img[i + 2]]
}
/// A paper point (between text rows, away from the stamp).
fn paper_points(w: usize, h: usize) -> Vec<(usize, usize)> {
[(0.05, 0.03), (0.5, 0.03), (0.95, 0.03), (0.05, 0.53), (0.5, 0.53), (0.97, 0.98), (0.03, 0.98)]
.iter()
.map(|&(fx, fy)| ((fx * w as f64) as usize, (fy * h as f64) as usize))
.map(|(x, y)| (x.min(w - 1), (y - y % 36 + 4).min(h - 1)))
.collect()
}
#[test]
fn a_shadow_and_a_yellow_cast_become_white_paper() {
let (w, h) = (480, 640);
// Warm room light, falling off to a third towards the bottom right (a hand's shadow).
let light = |x: usize, y: usize| {
let f = 1.0 - 0.66 * ((x + y) as f32 / (w + h) as f32);
[0.95 * f, 0.85 * f, 0.6 * f]
};
let img = photo(w, h, light);
let out = clean(&img, w as u32, h as u32, 60).unwrap();
for (x, y) in paper_points(w, h) {
let before = px(&img, w, x, y);
let after = px(&out, w, x, y);
assert!(after.iter().all(|&v| v >= 235), "({x}, {y}) {before:?} → {after:?}");
}
// The text stays dark everywhere, also in the shadow.
for (x, y) in [(w / 2, 13), (w / 2, 3 * 12 + 13), (w * 8 / 10, h - 40 - (h - 40) % 36 + 13)] {
let after = px(&out, w, x, y);
if (x / 9) % 6 != 0 {
assert!(after.iter().all(|&v| v <= 90), "text at ({x}, {y}): {after:?}");
}
}
}
#[test]
fn a_blue_stamp_stays_blue() {
let (w, h) = (480, 640);
let img = photo(w, h, |_, _| [0.9, 0.85, 0.7]);
let out = clean(&img, w as u32, h as u32, 100).unwrap();
// The stamp's centre, on a row without text.
let [r, g, b] = px(&out, w, 3 * w / 4, 3 * h / 4 + 12);
assert!(b as i32 - r as i32 > 60 && b as i32 - g as i32 > 40, "{:?}", [r, g, b]);
}
#[test]
fn level_zero_changes_nothing_and_strength_grows_with_the_level() {
let (w, h) = (240, 320);
let img = photo(w, h, |x, _| [0.6 + 0.3 * x as f32 / w as f32; 3]);
assert_eq!(clean(&img, w as u32, h as u32, 0).unwrap(), img);
let mean = |v: &[u8]| v.iter().map(|&c| c as f64).sum::<f64>() / v.len() as f64;
let (a, b, c) = (mean(&img), mean(&clean(&img, w as u32, h as u32, 30).unwrap()), mean(&clean(&img, w as u32, h as u32, 100).unwrap()));
assert!(a < b && b < c, "{a} {b} {c}");
}
#[test]
fn specks_go_at_high_levels_but_text_stays() {
let (w, h) = (240, 320);
let img = photo(w, h, |_, _| [1.0; 3]);
let out = clean(&img, w as u32, h as u32, 100).unwrap();
let specks: Vec<(usize, usize)> = (0..h)
.flat_map(|y| (0..w).map(move |x| (x, y)))
// Specks away from the text rows (one touching a letter isn't isolated).
.filter(|&(x, y)| (x * 7919 + y * 104729) % 997 == 0 && (y / 12) % 3 == 0 && (2..10).contains(&(y % 12)))
.collect();
assert!(!specks.is_empty());
for (x, y) in specks {
assert!(px(&out, w, x, y).iter().all(|&v| v > 200), "speck at ({x}, {y})");
}
assert!(px(&out, w, w / 2 + 1, 13).iter().all(|&v| v < 60), "text");
}
#[test]
fn the_looks() {
let (w, h) = (240, 320);
let img = photo(w, h, |x, _| [0.95 - 0.4 * x as f32 / w as f32, 0.85 - 0.4 * x as f32 / w as f32, 0.65]);
assert_eq!(apply(&img, w as u32, h as u32, Look::Original, 80), Some(Pixels::Rgb(img.clone())));
let Some(Pixels::Rgb(colour)) = apply(&img, w as u32, h as u32, Look::Colour, 80) else { panic!("colour") };
assert_eq!(Some(colour), clean(&img, w as u32, h as u32, 80));
let Some(Pixels::Grey(grey)) = apply(&img, w as u32, h as u32, Look::Greyscale, 80) else { panic!("grey") };
assert_eq!(grey.len(), w * h);
let Some(Pixels::Grey(bw)) = apply(&img, w as u32, h as u32, Look::BlackWhite, 50) else { panic!("bw") };
assert!(bw.iter().all(|&v| v == 0 || v == 255), "only black and white");
// Paper (between rows, across the shadow) is white; text is black.
for x in [5, w / 2, w - 6] {
assert_eq!(bw[3 * w + x], 255, "paper at x {x}");
}
assert_eq!(bw[13 * w + w / 2 + 1], 0, "text");
// The level moves the threshold: more black at the top than at the bottom.
let black = |level| match apply(&img, w as u32, h as u32, Look::BlackWhite, level) {
Some(Pixels::Grey(g)) => g.iter().filter(|&&v| v == 0).count(),
_ => 0,
};
assert!(black(0) <= black(50) && black(50) <= black(100), "{} {} {}", black(0), black(50), black(100));
for look in Look::ALL {
assert_eq!(Look::from_name(look.name()), Some(look));
}
assert_eq!(apply(&[0; 5], 2, 1, Look::BlackWhite, 50), None);
}
#[test]
fn bad_sizes_are_none_not_a_panic() {
assert_eq!(clean(&[], 0, 0, 50), None);
assert_eq!(clean(&[0; 12], 2, 3, 50), None);
assert_eq!(clean(&[9; 3], 1, 1, 255).map(|v| v.len()), Some(3));
}
}
+1
View File
@@ -14,6 +14,7 @@
pub mod actions;
pub mod camera;
pub mod camera_clean;
pub mod camera_edges;
pub mod catalog;
pub mod commands;
+33
View File
@@ -3269,6 +3269,39 @@ fn detect_camera_corners_finds_the_sheet_in_user_space() {
assert_eq!(s.detect_camera_corners(id, 7), None);
}
#[test]
fn enhance_camera_page_at_a_level_whitens_a_shadowed_sheet() {
// A sheet lit unevenly: full light at the left, a third of it at the right.
let img = image::RgbImage::from_fn(400, 300, |x, y| {
let light = 1.0 - 0.66 * x as f32 / 400.0;
let ink = if (y / 10) % 3 == 1 && (40..360).contains(&x) { 0.12 } else { 0.96 };
let v = (ink * light * 255.0) as u8;
image::Rgb([v, v, (v as f32 * 0.85) as u8])
});
let mut png = Vec::new();
img.write_to(&mut std::io::Cursor::new(&mut png), image::ImageFormat::Png).unwrap();
let whole = [[0.0, 0.0], [400.0, 0.0], [400.0, 300.0], [0.0, 300.0]];
let paper_at_right = |level: u8| {
let mut s = Session::new().with_clock(|| 1_700_000_000);
let bytes = s.create_from_images_with_resolution(&[("photo.png".into(), png.clone())], ImageResolution::Dpi(72.0)).unwrap();
let id = s.open("photo.pdf", None, bytes, None).unwrap();
s.enhance_camera_page_at(id, 0, whole, camera_clean::Look::Colour, level).unwrap();
assert_eq!(s.get(id).unwrap().can_undo(), Some("Enhance camera image"), "one step at level {level}");
let doc = s.get(id).unwrap();
let shot = pdfcraft_render::PageRenderer::new(doc.bytes.clone(), doc.config.clone()).render(pdfcraft_render::RenderRequest {
page: 0,
scale: 1.0,
..Default::default()
});
// A paper pixel near the right edge, between text rows.
let i = ((5 * shot.width + shot.width - 12) * 4) as usize;
shot.rgba[i..i + 3].to_vec()
};
let (before, after) = (paper_at_right(0), paper_at_right(80));
assert!(before.iter().all(|&v| v < 140), "the shadow: {before:?}");
assert!(after.iter().all(|&v| v > 235), "white paper: {after:?}");
}
#[test]
fn enhance_camera_page_refuses_bad_corners_and_pages() {
let mut s = Session::new().with_clock(|| 1_700_000_000);
+50 -2
View File
@@ -6,11 +6,14 @@ use egui::{Align2, Color32, CornerRadius, Pos2, Rect, Shape, Stroke, vec2};
use crate::canvas::{DocView, PageXform, ViewAction};
use crate::theme::Tokens;
pub use pdfcraft_engine::camera_clean::Look;
/// How far in from the page's edges the corners start, as a share of its size.
const INSET: f32 = 0.05;
/// How close (screen points) the pointer must be to pick up a corner.
const GRAB: f32 = 12.0;
/// The slider's top.
pub const MAX_LEVEL: u8 = pdfcraft_engine::camera_clean::MAX_LEVEL;
const BLUE: Color32 = Color32::from_rgb(0x14, 0x73, 0xE6);
/// The quadrilateral being placed: its page, its corners in view space (points, y down) and the
@@ -30,6 +33,26 @@ impl CameraQuad {
}
}
/// The clean-up level a page is enhanced at before the slider is touched.
pub const DEFAULT_LEVEL: u8 = 50;
/// A page just enhanced: what the bar's slider needs to enhance it again at another level
/// (from the straightened photo, keeping the turns made since).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct CameraDone {
pub page: usize,
/// The corners it was straightened from, in PDF user space.
pub corners: [[f64; 2]; 4],
/// The look and slider level chosen, and the ones the page shows.
pub look: Look,
pub level: u8,
pub applied: (Look, u8),
/// Quarter turns made since, in degrees (clockwise positive).
pub turns: i64,
/// The undo steps the enhancement and the turns took.
pub steps: usize,
}
fn screen(xf: &PageXform, c: [f32; 2]) -> Pos2 {
xf.norm_to_screen(c[0] / xf.pw.max(1e-3), c[1] / xf.ph.max(1e-3))
}
@@ -115,7 +138,8 @@ pub(crate) fn paint(painter: &egui::Painter, xf: &PageXform, page: usize, view:
/// The floating bar at the top right of the document area (where the find bar goes, clear of a
/// centred page's corners): Enhance page and Cancel; once the page is enhanced, rotate it either
/// way, Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`).
/// way, choose its look (original, colour, greyscale, black and white) and clean-up level,
/// Crop pages, Organize pages, or Done. The instructions are a notice (`ocr.enhance_camera`).
pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Tokens, allowed: bool) {
egui::Area::new(egui::Id::new("camera-bar"))
.order(egui::Order::Middle)
@@ -131,7 +155,31 @@ pub(crate) fn bar(ctx: &egui::Context, view: &mut DocView, area: Rect, t: &Token
.show(ui, |ui| {
ui.horizontal(|ui| {
// After Enhance page: put the page the right way up, trim it, or move on.
if view.camera_done.is_some() {
if let Some(done) = view.camera_done.as_mut() {
let mut again = false;
for (look, label) in [
(Look::Original, tl!("Original")),
(Look::Colour, tl!("Colour")),
(Look::Greyscale, tl!("Greyscale")),
(Look::BlackWhite, tl!("Black and white")),
] {
if ui.add_enabled(allowed, egui::Button::selectable(done.look == look, label)).clicked() && done.look != look {
done.look = look;
again = true;
}
}
ui.separator();
ui.label(tl!("Clean-up"));
let slider = ui.add_enabled(
allowed && done.look != Look::Original,
egui::Slider::new(&mut done.level, 0..=crate::camera_ui::MAX_LEVEL).show_value(false),
);
// Enhance again once the slider is let go (or moved with the keys).
again |= slider.drag_stopped() || (slider.changed() && !slider.dragged());
if again && (done.look, done.level) != done.applied {
view.pending_action = Some(ViewAction::CameraLevel);
}
ui.separator();
let icon = |ui: &mut egui::Ui, name: &str, tip: &str| {
let r = ui.add_enabled(allowed, egui::Button::image(crate::icons::image(name, 18.0, t.text)).frame(false));
// Named for screen readers (and tests), not only on hover.
+3 -1
View File
@@ -412,7 +412,7 @@ pub struct DocView {
pub camera: Option<crate::camera_ui::CameraQuad>,
/// Enhance ▸ Camera image: the page just enhanced, while the bar offers rotate, crop and
/// organize.
pub camera_done: Option<usize>,
pub camera_done: Option<crate::camera_ui::CameraDone>,
/// Use a certificate: a signature rectangle being drawn, or an empty signature field clicked.
pub sign: crate::sign_ui::SignView,
/// Organize: the pages being dragged to a new place.
@@ -494,6 +494,8 @@ pub enum ViewAction {
CameraRotate {
clockwise: bool,
},
/// After Enhance page: enhance again at the slider's clean-up level.
CameraLevel,
/// After Enhance page: leave the tool for this command (Crop pages, Organize pages).
CameraNext(&'static str),
}
+68 -5
View File
@@ -151,10 +151,17 @@ impl PdfCraftApp {
Some(crate::canvas::ViewAction::EnhanceCamera) => self.enhance_camera(i),
Some(crate::canvas::ViewAction::CancelCamera) => self.leave_camera(i),
Some(crate::canvas::ViewAction::CameraRotate { clockwise }) => {
if let Some(page) = self.views.get(i).and_then(|v| v.camera_done) {
self.apply_edit(Edit::RotatePages { pages: vec![page], degrees: if clockwise { 90 } else { -90 } });
if let Some(done) = self.views.get(i).and_then(|v| v.camera_done) {
let degrees = if clockwise { 90 } else { -90 };
if self.apply_edit(Edit::RotatePages { pages: vec![done.page], degrees })
&& let Some(d) = self.views.get_mut(i).and_then(|v| v.camera_done.as_mut())
{
d.turns += degrees;
d.steps += 1;
}
}
}
Some(crate::canvas::ViewAction::CameraLevel) => self.camera_level(i),
Some(crate::canvas::ViewAction::CameraNext(command)) => {
self.leave_camera(i);
self.execute(command);
@@ -164,6 +171,60 @@ impl PdfCraftApp {
applied
}
/// Enhance ▸ Camera image ▸ clean-up slider: undo the enhancement (and the turns made since)
/// and enhance again from the photo at the new level, turning it as before. Only when the
/// top of the undo history is still those steps; otherwise the slider goes back.
fn camera_level(&mut self, i: usize) {
let Some(view) = self.views.get(i) else { return };
let (id, Some(done)) = (view.id, view.camera_done) else { return };
let ours = |s: &pdfcraft_engine::Session, k: usize| {
let label = s.get(id).and_then(|d| d.can_undo().map(str::to_owned));
let want = if k + 1 == done.steps { "Enhance camera image" } else { "Rotate page" };
label.as_deref() == Some(want)
};
// Undo the run this tool applied, checking each step's label first. If the run was
// broken (another edit came in between), redo what was undone and leave the page alone:
// enhancing a page that is still straightened would warp it a second time.
let restore = |app: &mut Self| {
if let Some(d) = app.views.get_mut(i).and_then(|v| v.camera_done.as_mut()) {
(d.look, d.level) = d.applied;
}
};
let mut undone = 0;
while undone < done.steps && ours(&self.session, undone) && self.session.undo(id).is_ok() {
undone += 1;
}
if undone < done.steps {
for _ in 0..undone {
if self.session.redo(id).is_err() {
break;
}
}
restore(self);
return;
}
let result = self.session.enhance_camera_page_at(id, done.page, done.corners, done.look, done.level).and_then(|()| {
let turn = done.turns.rem_euclid(360);
if turn == 0 { Ok(1) } else { self.session.apply(id, Edit::RotatePages { pages: vec![done.page], degrees: turn }).map(|()| 2) }
});
if let (Some(doc), Some(view)) = (self.session.get(id), self.views.get_mut(i)) {
view.document_changed(&doc.info);
if let Some(d) = view.camera_done.as_mut() {
match result {
Ok(steps) => {
d.applied = (d.look, d.level);
d.steps = steps;
}
Err(_) => (d.look, d.level) = d.applied,
}
}
}
if let Err(e) = result {
let label = crate::i18n::action_label("Enhance camera image");
self.notify_fmt("{label} failed: {e}", &[("label", &label), ("e", &e.to_string())]);
}
}
/// Leave Enhance ▸ Camera image on tab `i`.
fn leave_camera(&mut self, i: usize) {
if let Some(view) = self.views.get_mut(i) {
@@ -186,13 +247,15 @@ impl PdfCraftApp {
let [u, v] = page.view_to_user(x, y);
[u as f64, v as f64]
});
match self.session.enhance_camera_page(id, q.page, corners) {
let (look, level) = (crate::camera_ui::Look::Colour, crate::camera_ui::DEFAULT_LEVEL);
match self.session.enhance_camera_page_at(id, q.page, corners, look, level) {
Ok(()) => {
// The tool stays open with rotate, crop and organize for the enhanced page.
// The tool stays open with the clean-up slider, rotate, crop and organize.
if let (Some(doc), Some(view)) = (self.session.get(id), self.views.get_mut(i)) {
view.document_changed(&doc.info);
view.camera = None;
view.camera_done = Some(q.page);
view.camera_done =
Some(crate::camera_ui::CameraDone { page: q.page, corners, look, level, applied: (look, level), turns: 0, steps: 1 });
}
}
Err(e) => {
+4
View File
@@ -2254,3 +2254,7 @@ scroll speed Fast سريعة
Enhance camera image تحسين صورة الكاميرا
Drag the corners onto the document's edges, then choose Enhance page. اسحب الزوايا إلى حواف المستند، ثم اختر تحسين الصفحة.
Enhance page تحسين الصفحة
Clean-up تنظيف
Original الأصل
Greyscale تدرج رمادي
Black and white أبيض وأسود
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2196,3 +2196,7 @@ scroll speed Fast Висока
Enhance camera image Подобри снимка от камера
Drag the corners onto the document's edges, then choose Enhance page. Плъзнете ъглите до краищата на документа, след това изберете „Подобри страницата“.
Enhance page Подобри страницата
Clean-up Почистване
Original Оригинал
Greyscale Нива на сивото
Black and white Черно-бяло
Can't render this file because it contains an unexpected character in line 8 and column 16.
+4
View File
@@ -2234,3 +2234,7 @@ scroll speed Fast Schnell
Enhance camera image Kamerafoto verbessern
Drag the corners onto the document's edges, then choose Enhance page. Ziehen Sie die Ecken auf die Kanten des Dokuments und wählen Sie dann Seite verbessern.
Enhance page Seite verbessern
Clean-up Bereinigung
Original Original
Greyscale Graustufen
Black and white Schwarzweiß
Can't render this file because it contains an unexpected character in line 6 and column 51.
+4
View File
@@ -2142,3 +2142,7 @@ scroll speed Fast Rápida
Enhance camera image Mejorar foto de cámara
Drag the corners onto the document's edges, then choose Enhance page. Arrastre las esquinas hasta los bordes del documento y elija Mejorar página.
Enhance page Mejorar página
Clean-up Limpieza
Original Original
Greyscale Escala de grises
Black and white Blanco y negro
Can't render this file because it contains an unexpected character in line 6 and column 60.
+4
View File
@@ -2234,3 +2234,7 @@ scroll speed Fast Rapide
Enhance camera image Améliorer la photo d'un document
Drag the corners onto the document's edges, then choose Enhance page. Faites glisser les coins sur les bords du document, puis choisissez Améliorer la page.
Enhance page Améliorer la page
Clean-up Nettoyage
Original Original
Greyscale Niveaux de gris
Black and white Noir et blanc
Can't render this file because it contains an unexpected character in line 6 and column 39.
+4
View File
@@ -2232,3 +2232,7 @@ scroll speed Fast Gyors
Enhance camera image Kamerakép javítása
Drag the corners onto the document's edges, then choose Enhance page. Húzza a sarkokat a dokumentum széleire, majd válassza az Oldal javítása lehetőséget.
Enhance page Oldal javítása
Clean-up Tisztítás
Original Eredeti
Greyscale Szürkeárnyalatos
Black and white Fekete-fehér
Can't render this file because it contains an unexpected character in line 7 and column 44.
+4
View File
@@ -2241,3 +2241,7 @@ scroll speed Fast Veloce
Enhance camera image Migliora foto della fotocamera
Drag the corners onto the document's edges, then choose Enhance page. Trascina gli angoli sui bordi del documento, poi scegli Migliora la pagina.
Enhance page Migliora la pagina
Clean-up Pulizia
Original Originale
Greyscale Scala di grigi
Black and white Bianco e nero
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2234,3 +2234,7 @@ scroll speed Fast 速い
Enhance camera image カメラ画像を補正
Drag the corners onto the document's edges, then choose Enhance page. 四隅を文書の端までドラッグしてから、「ページを補正」を選択します。
Enhance page ページを補正
Clean-up クリーンアップ
Original オリジナル
Greyscale グレースケール
Black and white 白黒
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2220,3 +2220,7 @@ scroll speed Fast Szybka
Enhance camera image Popraw zdjęcie z aparatu
Drag the corners onto the document's edges, then choose Enhance page. Przeciągnij narożniki na krawędzie dokumentu, a następnie wybierz Popraw stronę.
Enhance page Popraw stronę
Clean-up Oczyszczanie
Original Oryginał
Greyscale Skala szarości
Black and white Czarno-biały
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2247,3 +2247,7 @@ scroll speed Fast Rápida
Enhance camera image Aprimorar foto da câmera
Drag the corners onto the document's edges, then choose Enhance page. Arraste os cantos até as bordas do documento e escolha Aprimorar página.
Enhance page Aprimorar página
Clean-up Limpeza
Original Original
Greyscale Escala de cinza
Black and white Preto e branco
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -2147,3 +2147,7 @@ scroll speed Fast Высокая
Enhance camera image Улучшить снимок с камеры
Drag the corners onto the document's edges, then choose Enhance page. Перетащите углы к краям документа, затем выберите «Улучшить страницу».
Enhance page Улучшить страницу
Clean-up Очистка
Original Оригинал
Greyscale Оттенки серого
Black and white Чёрно-белый
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -2156,3 +2156,7 @@ scroll speed Fast వేగంగా
Enhance camera image కెమెరా చిత్రాన్ని మెరుగుపరచు
Drag the corners onto the document's edges, then choose Enhance page. మూలలను పత్రం అంచుల వరకు లాగి, ఆపై పేజీని మెరుగుపరచు ఎంచుకోండి.
Enhance page పేజీని మెరుగుపరచు
Clean-up శుభ్రపరచడం
Original అసలు
Greyscale గ్రేస్కేల్
Black and white నలుపు తెలుపు
Can't render this file because it contains an unexpected character in line 13 and column 16.
+4
View File
@@ -2255,3 +2255,7 @@ scroll speed Fast Швидка
Enhance camera image Поліпшити знімок з камери
Drag the corners onto the document's edges, then choose Enhance page. Перетягніть кути до країв документа, потім виберіть «Поліпшити сторінку».
Enhance page Поліпшити сторінку
Clean-up Очищення
Original Оригінал
Greyscale Відтінки сірого
Black and white Чорно-білий
Can't render this file because it contains an unexpected character in line 6 and column 47.
+4
View File
@@ -2198,3 +2198,7 @@ scroll speed Fast 快
Enhance camera image 增强相机图像
Drag the corners onto the document's edges, then choose Enhance page. 将四个角拖到文档边缘,然后选择“增强页面”。
Enhance page 增强页面
Clean-up 清理
Original 原始
Greyscale 灰度
Black and white 黑白
Can't render this file because it contains an unexpected character in line 9 and column 16.
+4
View File
@@ -2113,3 +2113,7 @@ scroll speed Fast 快
Enhance camera image 增強相機影像
Drag the corners onto the document's edges, then choose Enhance page. 將四個角拖曳到文件邊緣,然後選擇「增強頁面」。
Enhance page 增強頁面
Clean-up 清理
Original 原始
Greyscale 灰階
Black and white 黑白
Can't render this file because it contains an unexpected character in line 9 and column 16.
+93 -2
View File
@@ -74,7 +74,7 @@ fn dragging_a_corner_then_enhance_page_straightens_the_page() {
// The tool stays open for the enhanced page: turn it, then Done.
assert_eq!(h.state().quick_tool, QuickTool::EnhanceCamera);
assert!(h.state().views[0].camera.is_none());
assert_eq!(h.state().views[0].camera_done, Some(0));
assert_eq!(h.state().views[0].camera_done.map(|d| d.page), Some(0));
h.get_by_label("Rotate clockwise").click();
h.run_steps(4);
let (w, hh) = size(&h, 0);
@@ -86,6 +86,58 @@ fn dragging_a_corner_then_enhance_page_straightens_the_page() {
assert_eq!(h.state().views[0].camera_done, None);
}
#[test]
fn the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn() {
let mut h = harness();
assert!(h.state_mut().execute("ocr.enhance_camera"));
h.run_steps(3);
h.get_by_label("Enhance page").click();
h.run_steps(4);
let done = h.state().views[0].camera_done.unwrap();
assert_eq!((done.look, done.level, done.steps), (pdfcraft_engine::camera_clean::Look::Colour, 50, 1), "enhanced in colour at the default level");
h.get_by_label("Rotate clockwise").click();
h.run_steps(4);
let turned = size(&h, 0);
assert!(turned.0 > turned.1, "{turned:?}");
// Let go of the slider at 100.
let view = &mut h.state_mut().views[0];
if let Some(d) = view.camera_done.as_mut() {
d.level = 100;
}
view.pending_action = Some(pdfcraft_ui_egui::canvas::ViewAction::CameraLevel);
h.run_steps(4);
let done = h.state().views[0].camera_done.unwrap();
assert_eq!((done.applied.1, done.steps, done.turns), (100, 2, 90));
assert_eq!(size(&h, 0), turned, "still turned");
// The history holds one enhancement and one turn, not a pile of them.
let s = h.state_mut();
let id = s.views[0].id;
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
assert_eq!(s.session.undo(id).unwrap(), "Enhance camera image");
assert_eq!(s.session.get(id).unwrap().can_undo(), None);
let p = &s.session.get(id).unwrap().info.pages[0];
assert_eq!((p.width, p.height), (300.0, 400.0), "back to the photo");
}
#[test]
fn choosing_a_look_enhances_again() {
use pdfcraft_engine::camera_clean::Look;
let mut h = harness();
assert!(h.state_mut().execute("ocr.enhance_camera"));
h.run_steps(3);
h.get_by_label("Enhance page").click();
h.run_steps(4);
for (label, look) in [("Black and white", Look::BlackWhite), ("Greyscale", Look::Greyscale), ("Original", Look::Original)] {
h.get_by_label(label).click();
h.run_steps(4);
let done = h.state().views[0].camera_done.unwrap();
assert_eq!((done.applied.0, done.steps), (look, 1), "{label}");
let s = h.state();
assert_eq!(s.session.get(s.views[0].id).unwrap().can_undo(), Some("Enhance camera image"), "{label}");
}
}
#[test]
fn after_enhance_page_crop_and_organize_take_over() {
for (button, then) in [("Crop pages", QuickTool::Crop), ("Organize pages", QuickTool::Select)] {
@@ -94,7 +146,7 @@ fn after_enhance_page_crop_and_organize_take_over() {
h.run_steps(3);
h.get_by_label("Enhance page").click();
h.run_steps(4);
assert_eq!(h.state().views[0].camera_done, Some(0));
assert_eq!(h.state().views[0].camera_done.map(|d| d.page), Some(0));
h.get_by_label(button).click();
h.run_steps(3);
assert_eq!(h.state().quick_tool, then, "{button}");
@@ -123,3 +175,42 @@ fn cancel_and_escape_leave_the_page_alone() {
assert_eq!(size(&h, 0), (300.0, 400.0));
assert_eq!(undo_label(&h), None);
}
/// A turn made outside the tool after the enhancement breaks its run in the history: changing the
/// level then leaves the page and the history as they are instead of enhancing the straightened
/// page a second time.
#[test]
fn the_level_leaves_the_page_alone_when_another_edit_came_in_between() {
let mut h = harness();
assert!(h.state_mut().execute("ocr.enhance_camera"));
h.run_steps(3);
h.get_by_label("Enhance page").click();
h.run_steps(4);
h.get_by_label("Rotate clockwise").click();
h.run_steps(4);
// Another "Rotate page" from outside the tool, with the same history label.
{
let s = h.state_mut();
let id = s.views[0].id;
s.session.apply(id, pdfcraft_engine::Edit::RotatePages { pages: vec![0], degrees: 90 }).unwrap();
}
h.run_steps(2);
let before = size(&h, 0);
let applied = h.state().views[0].camera_done.unwrap().applied;
let view = &mut h.state_mut().views[0];
if let Some(d) = view.camera_done.as_mut() {
d.level = 100;
}
view.pending_action = Some(pdfcraft_ui_egui::canvas::ViewAction::CameraLevel);
h.run_steps(4);
assert_eq!(size(&h, 0), before, "the page is not warped again");
let done = h.state().views[0].camera_done.unwrap();
assert_eq!((done.level, done.applied), (applied.1, applied), "the slider goes back");
let s = h.state_mut();
let id = s.views[0].id;
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
assert_eq!(s.session.undo(id).unwrap(), "Rotate page");
assert_eq!(s.session.undo(id).unwrap(), "Enhance camera image");
assert_eq!(s.session.get(id).unwrap().can_undo(), None);
}
+16 -16
View File
@@ -6555,11 +6555,11 @@ title = "Despeckle"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.enhance_camera"]
tools = ["page_enhance_camera"]
evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
notes = "Camera images only: from clean-up level 60, isolated dark pixels are removed. Missing: for scanned pages and as an OCR option."
[[feature]]
id = "ocr.background-removal"
@@ -6567,11 +6567,11 @@ title = "Background removal"
area = "I"
tier = "P1"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.enhance_camera"]
tools = ["page_enhance_camera"]
evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
notes = "Camera images only: Enhance camera image's clean-up level (and page_enhance_camera level) evens out the paper's illumination per channel, so the background turns white and colour casts go; colour ink stays. Missing: the same for scanned pages and as an OCR option."
[[feature]]
id = "ocr.descreen"
@@ -6603,11 +6603,11 @@ title = "Edge shadow removal"
area = "I"
tier = "P2"
milestone = "M10"
status = "planned"
commands = []
tools = []
evidence = []
notes = ""
status = "partial"
commands = ["ocr.enhance_camera"]
tools = ["page_enhance_camera"]
evidence = ["crates/engine/src/camera_clean.rs::a_shadow_and_a_yellow_cast_become_white_paper", "crates/engine/src/tests.rs::enhance_camera_page_at_a_level_whitens_a_shadowed_sheet", "crates/ui-egui/tests/camera.rs::the_clean_up_level_enhances_again_from_the_photo_and_keeps_the_turn"]
notes = "Camera images only: the clean-up level divides out a smooth illumination estimate, which removes shadows and darker edges on the straightened photo. Missing: for scanned pages (book gutters) and as an OCR option."
[[feature]]
id = "ocr.camera-perspective"
@@ -6619,7 +6619,7 @@ status = "partial"
commands = ["ocr.enhance_camera"]
tools = ["page_enhance_camera"]
evidence = ["crates/engine/src/tests.rs::enhance_camera_page_straightens_the_sheet", "crates/automation/tests/automation.rs::enhance_camera_image_through_tools", "crates/ui-egui/tests/camera.rs::dragging_a_corner_then_enhance_page_straightens_the_page", "crates/ui-egui/tests/camera.rs::after_enhance_page_crop_and_organize_take_over", "crates/engine/src/tests.rs::detect_camera_corners_finds_the_sheet_in_user_space", "crates/automation/tests/automation.rs::enhance_camera_image_finds_the_edges_by_itself"]
notes = "Four corners (any order, a convex quadrilateral) are warped to an upright page at the picture's own resolution, in one undoable step; comments, links and bookmarks stay. Scan & OCR ▸ Enhance camera image shows four corner handles on the current page (Enhance page / Cancel / Esc), then offers rotate either way, Crop pages and Organize pages for the enhanced page; page_enhance_camera does the same headlessly. The corners start on the document's edges when they are found (straight lines with an edge along most of each side, refined by a line fit; tested on synthetic photos with text, wood grain, shadows, strong perspective and a 30° turn), else inset 5%. Missing: turning the result upright (text orientation), rounded-corner cards keep their background in the corners, contrast and background cleanup, document types."
notes = "Four corners (any order, a convex quadrilateral) are warped to an upright page at the picture's own resolution, in one undoable step; comments, links and bookmarks stay. Scan & OCR ▸ Enhance camera image shows four corner handles on the current page (Enhance page / Cancel / Esc), then offers rotate either way, Crop pages and Organize pages for the enhanced page; page_enhance_camera does the same headlessly. The corners start on the document's edges when they are found (straight lines with an edge along most of each side, refined by a line fit; tested on synthetic photos with text, wood grain, shadows, strong perspective and a 30° turn), else inset 5%. After Enhance page a clean-up slider (0–100, default 50) re-enhances from the straightened photo: even lighting, white paper, contrast, despeckle from 60 (camera_clean). Missing: turning the result upright (text orientation), rounded-corner cards keep their background in the corners, document types."
[[feature]]
id = "ocr.camera-document-type"