From 8f8e0236aaef16a4f3ca52a074ad5d48da6498f7 Mon Sep 17 00:00:00 2001 From: magdale76 Date: Sun, 11 Oct 2026 20:57:31 +0200 Subject: [PATCH] M10: Enhance camera image cleans the photo up and chooses its look (part 4 of #1008, closes #1034) (#1038) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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 * 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 * 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 * 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 * 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 * 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 * 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 Co-authored-by: Brandon Thomas --- ATTRIBUTION.toml | 30 +- crates/automation/src/lib.rs | 12 +- crates/automation/src/tools.rs | 4 +- crates/automation/tests/automation.rs | 4 + crates/engine/src/camera.rs | 62 +++- crates/engine/src/camera_clean.rs | 392 ++++++++++++++++++++++++++ crates/engine/src/lib.rs | 1 + crates/engine/src/tests.rs | 33 +++ crates/ui-egui/src/camera_ui.rs | 52 +++- crates/ui-egui/src/canvas.rs | 4 +- crates/ui-egui/src/editing.rs | 73 ++++- crates/ui-egui/src/i18n/ar.tsv | 4 + crates/ui-egui/src/i18n/bg.tsv | 4 + crates/ui-egui/src/i18n/de.tsv | 4 + crates/ui-egui/src/i18n/es.tsv | 4 + crates/ui-egui/src/i18n/fr.tsv | 4 + crates/ui-egui/src/i18n/hu.tsv | 4 + crates/ui-egui/src/i18n/it.tsv | 4 + crates/ui-egui/src/i18n/ja.tsv | 4 + crates/ui-egui/src/i18n/pl.tsv | 4 + crates/ui-egui/src/i18n/pt-br.tsv | 4 + crates/ui-egui/src/i18n/ru.tsv | 4 + crates/ui-egui/src/i18n/te.tsv | 4 + crates/ui-egui/src/i18n/uk.tsv | 4 + crates/ui-egui/src/i18n/zh-hans.tsv | 4 + crates/ui-egui/src/i18n/zh-hant.tsv | 4 + crates/ui-egui/tests/camera.rs | 95 ++++++- parity/acrobat-features.toml | 32 +-- 28 files changed, 801 insertions(+), 53 deletions(-) create mode 100644 crates/engine/src/camera_clean.rs diff --git a/ATTRIBUTION.toml b/ATTRIBUTION.toml index a676de293..bd6cd6192 100644 --- a/ATTRIBUTION.toml +++ b/ATTRIBUTION.toml @@ -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" diff --git a/crates/automation/src/lib.rs b/crates/automation/src/lib.rs index 71ab531f5..3f73ebec7 100644 --- a/crates/automation/src/lib.rs +++ b/crates/automation/src/lib.rs @@ -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 = 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)?); diff --git a/crates/automation/src/tools.rs b/crates/automation/src/tools.rs index e2519fae9..467fc81d5 100644 --- a/crates/automation/src/tools.rs +++ b/crates/automation/src/tools.rs @@ -184,13 +184,15 @@ pub fn tools() -> Vec { }), &["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"], )), diff --git a/crates/automation/tests/automation.rs b/crates/automation/tests/automation.rs index 3110d3bee..be1c71346 100644 --- a/crates/automation/tests/automation.rs +++ b/crates/automation/tests/automation.rs @@ -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 = r["corners"].as_array().unwrap().iter().map(|v| v.as_f64().unwrap()).collect(); for (k, p) in sheet.iter().enumerate() { diff --git a/crates/engine/src/camera.rs b/crates/engine/src/camera.rs index e0457cf27..69318a80a 100644 --- a/crates/engine/src/camera.rs +++ b/crates/engine/src/camera.rs @@ -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] }) } diff --git a/crates/engine/src/camera_clean.rs b/crates/engine/src/camera_clean.rs new file mode 100644 index 000000000..9c4120be5 --- /dev/null +++ b/crates/engine/src/camera_clean.rs @@ -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::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), + Grey(Vec), +} + +/// 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 { + 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 = 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> { + 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 = + (-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 = 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 = 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 { + 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::() / 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)); + } +} diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index f7aacd400..8b53f0c34 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -14,6 +14,7 @@ pub mod actions; pub mod camera; +pub mod camera_clean; pub mod camera_edges; pub mod catalog; pub mod commands; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 79a531896..a74845dac 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -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); diff --git a/crates/ui-egui/src/camera_ui.rs b/crates/ui-egui/src/camera_ui.rs index 9b2db74fe..720ac7c6a 100644 --- a/crates/ui-egui/src/camera_ui.rs +++ b/crates/ui-egui/src/camera_ui.rs @@ -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. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 2a0246550..210a53536 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -412,7 +412,7 @@ pub struct DocView { pub camera: Option, /// Enhance ▸ Camera image: the page just enhanced, while the bar offers rotate, crop and /// organize. - pub camera_done: Option, + pub camera_done: Option, /// 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), } diff --git a/crates/ui-egui/src/editing.rs b/crates/ui-egui/src/editing.rs index dad07c1ff..9f176dbf4 100644 --- a/crates/ui-egui/src/editing.rs +++ b/crates/ui-egui/src/editing.rs @@ -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) => { diff --git a/crates/ui-egui/src/i18n/ar.tsv b/crates/ui-egui/src/i18n/ar.tsv index 3ab076d86..0d731aa28 100644 --- a/crates/ui-egui/src/i18n/ar.tsv +++ b/crates/ui-egui/src/i18n/ar.tsv @@ -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 أبيض وأسود diff --git a/crates/ui-egui/src/i18n/bg.tsv b/crates/ui-egui/src/i18n/bg.tsv index 6c546a51d..170c53119 100644 --- a/crates/ui-egui/src/i18n/bg.tsv +++ b/crates/ui-egui/src/i18n/bg.tsv @@ -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 Черно-бяло diff --git a/crates/ui-egui/src/i18n/de.tsv b/crates/ui-egui/src/i18n/de.tsv index 42440eb73..9c0156a13 100644 --- a/crates/ui-egui/src/i18n/de.tsv +++ b/crates/ui-egui/src/i18n/de.tsv @@ -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ß diff --git a/crates/ui-egui/src/i18n/es.tsv b/crates/ui-egui/src/i18n/es.tsv index c429304ee..2b387f584 100644 --- a/crates/ui-egui/src/i18n/es.tsv +++ b/crates/ui-egui/src/i18n/es.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/fr.tsv b/crates/ui-egui/src/i18n/fr.tsv index 6f6f0c1cd..d77e7a2f8 100644 --- a/crates/ui-egui/src/i18n/fr.tsv +++ b/crates/ui-egui/src/i18n/fr.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/hu.tsv b/crates/ui-egui/src/i18n/hu.tsv index 5a5de2425..fb9398dce 100644 --- a/crates/ui-egui/src/i18n/hu.tsv +++ b/crates/ui-egui/src/i18n/hu.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/it.tsv b/crates/ui-egui/src/i18n/it.tsv index 846b5d353..dfe54c34a 100644 --- a/crates/ui-egui/src/i18n/it.tsv +++ b/crates/ui-egui/src/i18n/it.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ja.tsv b/crates/ui-egui/src/i18n/ja.tsv index 547930b3c..e65c4615c 100644 --- a/crates/ui-egui/src/i18n/ja.tsv +++ b/crates/ui-egui/src/i18n/ja.tsv @@ -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 白黒 diff --git a/crates/ui-egui/src/i18n/pl.tsv b/crates/ui-egui/src/i18n/pl.tsv index ebf2b2abb..542ef9175 100644 --- a/crates/ui-egui/src/i18n/pl.tsv +++ b/crates/ui-egui/src/i18n/pl.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/pt-br.tsv b/crates/ui-egui/src/i18n/pt-br.tsv index 1720d59bc..c8e78428b 100644 --- a/crates/ui-egui/src/i18n/pt-br.tsv +++ b/crates/ui-egui/src/i18n/pt-br.tsv @@ -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 diff --git a/crates/ui-egui/src/i18n/ru.tsv b/crates/ui-egui/src/i18n/ru.tsv index b92659ca6..ac5566eaa 100644 --- a/crates/ui-egui/src/i18n/ru.tsv +++ b/crates/ui-egui/src/i18n/ru.tsv @@ -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 Чёрно-белый diff --git a/crates/ui-egui/src/i18n/te.tsv b/crates/ui-egui/src/i18n/te.tsv index 8f9ebb523..e15fb7f32 100644 --- a/crates/ui-egui/src/i18n/te.tsv +++ b/crates/ui-egui/src/i18n/te.tsv @@ -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 నలుపు తెలుపు diff --git a/crates/ui-egui/src/i18n/uk.tsv b/crates/ui-egui/src/i18n/uk.tsv index 3f5712537..a641d81a7 100644 --- a/crates/ui-egui/src/i18n/uk.tsv +++ b/crates/ui-egui/src/i18n/uk.tsv @@ -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 Чорно-білий diff --git a/crates/ui-egui/src/i18n/zh-hans.tsv b/crates/ui-egui/src/i18n/zh-hans.tsv index 1377d3e6a..7c053e64e 100644 --- a/crates/ui-egui/src/i18n/zh-hans.tsv +++ b/crates/ui-egui/src/i18n/zh-hans.tsv @@ -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 黑白 diff --git a/crates/ui-egui/src/i18n/zh-hant.tsv b/crates/ui-egui/src/i18n/zh-hant.tsv index 6ea75a7ab..d1e8f3b5e 100644 --- a/crates/ui-egui/src/i18n/zh-hant.tsv +++ b/crates/ui-egui/src/i18n/zh-hant.tsv @@ -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 黑白 diff --git a/crates/ui-egui/tests/camera.rs b/crates/ui-egui/tests/camera.rs index 3555a8506..ed0d4add4 100644 --- a/crates/ui-egui/tests/camera.rs +++ b/crates/ui-egui/tests/camera.rs @@ -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); +} diff --git a/parity/acrobat-features.toml b/parity/acrobat-features.toml index f26730e70..14b20f7be 100644 --- a/parity/acrobat-features.toml +++ b/parity/acrobat-features.toml @@ -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"