1use crate::error::ComputerError;
8use crate::image_util::ScreenshotMap;
9use image::RgbaImage;
10
11#[derive(Debug, Clone, PartialEq)]
13pub struct OcrBlock {
14 pub text: String,
16 pub x: i32,
18 pub y: i32,
20 pub width: u32,
22 pub height: u32,
24 pub confidence: f32,
26}
27
28#[derive(Debug, Clone, Default, PartialEq)]
30pub struct OcrResult {
31 pub blocks: Vec<OcrBlock>,
33 pub note: Option<String>,
35}
36
37pub fn recognize(image: &RgbaImage, _map: &ScreenshotMap) -> Result<OcrResult, ComputerError> {
43 #[cfg(target_os = "macos")]
44 {
45 macos::recognize(image)
46 }
47 #[cfg(target_os = "windows")]
48 {
49 windows::recognize(image)
50 }
51 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
52 {
53 let _ = image;
54 Err(unsupported_on_this_os())
55 }
56}
57
58#[derive(Debug, Clone, Copy, PartialEq, Eq)]
61pub struct ImageRect {
62 pub x: i32,
64 pub y: i32,
66 pub width: u32,
68 pub height: u32,
70}
71
72impl ImageRect {
73 pub fn contains(&self, other: &ImageRect) -> bool {
75 let other_right = other.x.saturating_add(other.width as i32);
76 let other_bottom = other.y.saturating_add(other.height as i32);
77 let self_right = self.x.saturating_add(self.width as i32);
78 let self_bottom = self.y.saturating_add(self.height as i32);
79 other.x >= self.x
80 && other.y >= self.y
81 && other_right <= self_right
82 && other_bottom <= self_bottom
83 }
84
85 pub fn to_screen(&self, map: &ScreenshotMap) -> Option<(f64, f64)> {
88 let center_x = self.x as f64 + (self.width as f64) / 2.0;
89 let center_y = self.y as f64 + (self.height as f64) / 2.0;
90 let content_w = f64::from(map.content_width.max(1));
91 let content_h = f64::from(map.content_height.max(1));
92 let pad_x = map.pad.x as f64;
93 let pad_y = map.pad.y as f64;
94 if center_x < pad_x || center_y < pad_y {
95 return None;
96 }
97 if center_x > pad_x + content_w || center_y > pad_y + content_h {
98 return None;
99 }
100 let logical_w = f64::from(map.logical_width.max(1));
101 let logical_h = f64::from(map.logical_height.max(1));
102 let screen_x = map.origin.x as f64 + (center_x - pad_x) * logical_w / content_w;
103 let screen_y = map.origin.y as f64 + (center_y - pad_y) * logical_h / content_h;
104 Some((screen_x, screen_y))
105 }
106}
107
108pub fn redact_blocks<I, R>(blocks: I, secure_frames: R) -> Vec<OcrBlock>
110where
111 I: IntoIterator<Item = OcrBlock>,
112 R: IntoIterator<Item = ImageRect>,
113{
114 let frames: Vec<ImageRect> = secure_frames.into_iter().collect();
115 if frames.is_empty() {
116 return blocks.into_iter().collect();
117 }
118 blocks
119 .into_iter()
120 .filter(|block| {
121 let image_rect = ImageRect {
122 x: block.x,
123 y: block.y,
124 width: block.width,
125 height: block.height,
126 };
127 !frames.iter().any(|frame| frame.contains(&image_rect))
128 })
129 .collect()
130}
131
132#[cfg_attr(any(target_os = "macos", target_os = "windows"), allow(dead_code))]
135fn unsupported_on_this_os() -> ComputerError {
136 #[cfg(target_os = "linux")]
137 {
138 ComputerError::Unsupported(
139 "no OCR backend on Linux in 0.2.0 — OCR returns unsupported".into(),
140 )
141 }
142 #[cfg(not(target_os = "linux"))]
143 {
144 ComputerError::Unsupported("ocr backend missing on this platform".into())
145 }
146}
147
148#[cfg_attr(all(not(target_os = "windows"), not(test)), allow(dead_code))]
155pub(crate) fn ocr_box_to_image(
156 image_width: u32,
157 image_height: u32,
158 x: f64,
159 y: f64,
160 width: f64,
161 height: f64,
162) -> ImageRect {
163 let max_x = image_width as f64;
164 let max_y = image_height as f64;
165 let left = x.round().clamp(0.0, max_x) as i32;
166 let top = y.round().clamp(0.0, max_y) as i32;
167 let right = (x + width).round().clamp(f64::from(left), max_x) as i32;
168 let bottom = (y + height).round().clamp(f64::from(top), max_y) as i32;
169 ImageRect {
170 x: left,
171 y: top,
172 width: (right - left).max(1) as u32,
173 height: (bottom - top).max(1) as u32,
174 }
175}
176
177#[cfg(target_os = "macos")]
179pub(crate) fn vision_box_to_image(image: &RgbaImage, rect: VisionRect) -> ImageRect {
180 let img_w = image.width() as f64;
181 let img_h = image.height() as f64;
182 let left = (rect.left.clamp(0.0, 1.0) * img_w).round() as i32;
183 let right = (rect.right.clamp(0.0, 1.0) * img_w).round() as i32;
184 let top_v = rect.top.clamp(0.0, 1.0);
185 let bot_v = rect.bottom.clamp(0.0, 1.0);
186 let flipped_top = (1.0 - bot_v) * img_h;
187 let flipped_bottom = (1.0 - top_v) * img_h;
188 let top = flipped_top.round().min(flipped_bottom.round()) as i32;
189 let bottom = flipped_top.round().max(flipped_bottom.round()) as i32;
190 let img_h_i = image.height() as i32;
191 ImageRect {
192 x: left.max(0),
193 y: top.max(0).min(img_h_i),
194 width: (right - left).max(1) as u32,
195 height: (bottom - top).max(1) as u32,
196 }
197}
198
199#[cfg(target_os = "macos")]
201#[derive(Debug, Clone, Copy, PartialEq)]
202pub(crate) struct VisionRect {
203 pub left: f64,
204 pub top: f64,
205 pub right: f64,
206 pub bottom: f64,
207}
208
209#[cfg(target_os = "macos")]
225mod macos {
226 use super::{vision_box_to_image, OcrBlock, OcrResult, VisionRect};
227 use crate::error::ComputerError;
228 use image::{ExtendedColorType, ImageEncoder, RgbaImage};
229 use objc2::rc::Retained;
230 use objc2::runtime::AnyObject;
231 use objc2::AnyThread;
232 use objc2_foundation::{NSArray, NSData, NSDictionary, NSString};
233 use objc2_vision::{
234 VNDetectedObjectObservation, VNImageRequestHandler, VNRecognizeTextRequest,
235 VNRecognizedText, VNRecognizedTextObservation, VNRequest, VNRequestTextRecognitionLevel,
236 };
237
238 #[allow(unsafe_code)]
240 pub fn recognize(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
241 let (img_w, img_h) = (image.width(), image.height());
242
243 let mut bytes = Vec::new();
246 let encoder = image::codecs::png::PngEncoder::new(&mut bytes);
247 encoder
248 .write_image(image.as_raw(), img_w, img_h, ExtendedColorType::Rgba8)
249 .map_err(|error| {
250 ComputerError::ExecutionFailed(format!("encode capture for OCR: {error}"))
251 })?;
252 let png = NSData::with_bytes(&bytes);
253
254 let options: Retained<NSDictionary<NSString, AnyObject>> = NSDictionary::new();
258
259 let handler = VNImageRequestHandler::initWithData_options(
264 VNImageRequestHandler::alloc(),
265 &png,
266 &options,
267 );
268
269 let request = VNRecognizeTextRequest::new();
273 request.setRecognitionLevel(VNRequestTextRecognitionLevel::Accurate);
274 request.setUsesLanguageCorrection(true);
275
276 let upcast: Retained<VNRequest> = request.clone().into_super().into_super();
283 let requests: Retained<NSArray<VNRequest>> = NSArray::from_retained_slice(&[upcast]);
284
285 if let Err(error) = handler.performRequests_error(&requests) {
289 return Err(ComputerError::ExecutionFailed(format!(
290 "VNImageRequestHandler performRequests failed: {error}"
291 )));
292 }
293
294 let observations = match request.results() {
298 Some(obs) => obs,
299 None => {
300 return Ok(OcrResult {
301 blocks: Vec::new(),
302 note: None,
303 })
304 }
305 };
306
307 let observations_ref: &NSArray<VNRecognizedTextObservation> = &observations;
311 let count = observations_ref.len();
312 let mut blocks = Vec::with_capacity(count);
313
314 for i in 0..count {
315 let observation_retained: Retained<VNRecognizedTextObservation> =
316 observations_ref.objectAtIndex(i);
317
318 let rect = unsafe {
332 let obs_super: Retained<VNDetectedObjectObservation> =
333 Retained::cast_unchecked(observation_retained.clone());
334 (*obs_super).boundingBox()
335 };
336
337 let vision_rect = VisionRect {
338 left: rect.origin.x,
339 bottom: rect.origin.y,
340 right: rect.origin.x + rect.size.width,
341 top: rect.origin.y + rect.size.height,
342 };
343
344 let candidates = observation_retained.topCandidates(1);
349 let candidate: Retained<VNRecognizedText> = match candidates.into_iter().next() {
350 Some(c) => c,
351 None => continue,
352 };
353 let text_ns = candidate.string();
354 let confidence: f32 = candidate.confidence();
355
356 let image_rect = vision_box_to_image(image, vision_rect);
357 blocks.push(OcrBlock {
358 text: text_ns.to_string(),
359 x: image_rect.x,
360 y: image_rect.y,
361 width: image_rect.width,
362 height: image_rect.height,
363 confidence,
364 });
365 }
366
367 Ok(OcrResult { blocks, note: None })
368 }
369}
370
371#[cfg(target_os = "windows")]
392mod windows {
393 #![allow(unsafe_code)]
397
398 use std::sync::mpsc::{self, RecvTimeoutError};
399 use std::thread;
400 use std::time::Duration;
401
402 use super::{ocr_box_to_image, OcrBlock, OcrResult};
403 use crate::error::ComputerError;
404 use image::RgbaImage;
405 use windows::Graphics::Imaging::{BitmapAlphaMode, BitmapPixelFormat, SoftwareBitmap};
406 use windows::Media::Ocr::{OcrEngine, OcrLine};
407 use windows::Security::Cryptography::CryptographicBuffer;
408
409 use windows::Win32::Foundation::S_FALSE;
410 use windows::Win32::System::Com::{
411 CoDecrementMTAUsage, CoIncrementMTAUsage, CO_MTA_USAGE_COOKIE,
412 };
413 use windows::Win32::System::WinRT::{RoInitialize, RoUninitialize, RO_INIT_MULTITHREADED};
414
415 const OCR_TIMEOUT: Duration = Duration::from_secs(10);
417
418 pub fn recognize(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
420 let image = image.clone();
421 let (sender, receiver) = mpsc::channel::<Result<OcrResult, ComputerError>>();
422 let worker = thread::Builder::new()
423 .name("computer-use-ocr".into())
424 .spawn(move || {
425 let result = recognize_on_worker(&image);
426 let _ = sender.send(result);
427 });
428 if worker.is_err() {
429 return Err(ComputerError::ExecutionFailed(
430 "Windows.Media.Ocr worker thread could not start".into(),
431 ));
432 }
433 match receiver.recv_timeout(OCR_TIMEOUT) {
434 Ok(result) => result,
435 Err(RecvTimeoutError::Timeout) => Err(ComputerError::ExecutionFailed(format!(
436 "Windows.Media.Ocr did not finish within {}s",
437 OCR_TIMEOUT.as_secs()
438 ))),
439 Err(RecvTimeoutError::Disconnected) => Err(ComputerError::ExecutionFailed(
440 "Windows.Media.Ocr worker stopped without a result".into(),
441 )),
442 }
443 }
444
445 fn recognize_on_worker(image: &RgbaImage) -> Result<OcrResult, ComputerError> {
447 let _mta = MtaUsage::new()?;
448 let _apartment = Apartment::new()?;
449
450 let (width, height) = (image.width(), image.height());
451 let bitmap = software_bitmap(image)?;
452 let engine = engine()?;
453
454 let operation = engine.RecognizeAsync(&bitmap).map_err(|error| {
455 ComputerError::ExecutionFailed(format!("Windows.Media.Ocr RecognizeAsync: {error}"))
456 })?;
457 let result = operation.join().map_err(|error| {
458 ComputerError::ExecutionFailed(format!("Windows.Media.Ocr RecognizeAsync: {error}"))
459 })?;
460
461 let mut blocks = Vec::new();
462 let lines = result.Lines().map_err(|error| {
463 ComputerError::ExecutionFailed(format!("Windows.Media.Ocr OcrResult.Lines: {error}"))
464 })?;
465 for line in &lines {
466 let Some((text, box_rect)) = line_block(&line) else {
467 continue;
468 };
469 let rect = ocr_box_to_image(
470 width, height, box_rect.0, box_rect.1, box_rect.2, box_rect.3,
471 );
472 blocks.push(OcrBlock {
473 text,
474 x: rect.x,
475 y: rect.y,
476 width: rect.width,
477 height: rect.height,
478 confidence: 0.0,
479 });
480 }
481
482 Ok(OcrResult {
483 blocks,
484 note: language_note(&engine),
485 })
486 }
487
488 fn line_block(line: &OcrLine) -> Option<(String, (f64, f64, f64, f64))> {
491 let words = line.Words().ok()?;
492 let mut text_parts: Vec<String> = Vec::new();
493 let mut left = f64::MAX;
494 let mut top = f64::MAX;
495 let mut right = f64::MIN;
496 let mut bottom = f64::MIN;
497 for word in &words {
498 let word_text = word.Text().ok()?.to_string();
499 if word_text.trim().is_empty() {
500 continue;
501 }
502 let rect = word.BoundingRect().ok()?;
503 text_parts.push(word_text);
504 let (x, y) = (f64::from(rect.X), f64::from(rect.Y));
505 left = left.min(x);
506 top = top.min(y);
507 right = right.max(x + f64::from(rect.Width));
508 bottom = bottom.max(y + f64::from(rect.Height));
509 }
510 if text_parts.is_empty() {
511 return None;
512 }
513 Some((
514 text_parts.join(" "),
515 (left, top, (right - left).max(1.0), (bottom - top).max(1.0)),
516 ))
517 }
518
519 fn engine() -> Result<OcrEngine, ComputerError> {
521 OcrEngine::TryCreateFromUserProfileLanguages().map_err(|error| {
522 ComputerError::ExecutionFailed(format!(
523 "Windows.Media.Ocr has no engine for the user-profile languages. Install a \
524 language pack (Settings > Time & Language > Language > Language options, or \
525 the OCR Recognizer in Settings > Apps > Optional features): {error}"
526 ))
527 })
528 }
529
530 fn language_note(engine: &OcrEngine) -> Option<String> {
532 let tag = engine
533 .RecognizerLanguage()
534 .ok()?
535 .LanguageTag()
536 .ok()?
537 .to_string();
538 if tag.is_empty() {
539 None
540 } else {
541 Some(format!("Media OCR recognizer language: {tag}"))
542 }
543 }
544
545 fn software_bitmap(image: &RgbaImage) -> Result<SoftwareBitmap, ComputerError> {
551 let (width, height) = (image.width(), image.height());
552 let width = i32::try_from(width).map_err(|_| {
553 ComputerError::ExecutionFailed(format!("capture width {width} is too large"))
554 })?;
555 let height = i32::try_from(height).map_err(|_| {
556 ComputerError::ExecutionFailed(format!("capture height {height} is too large"))
557 })?;
558 let max_edge = OcrEngine::MaxImageDimension().unwrap_or(0);
559 if max_edge > 0
560 && (u32::try_from(width).unwrap_or(u32::MAX) > max_edge
561 || u32::try_from(height).unwrap_or(u32::MAX) > max_edge)
562 {
563 return Err(ComputerError::ExecutionFailed(format!(
564 "Windows.Media.Ocr accepts at most {max_edge} px per edge; the capture is \
565 {width}x{height}"
566 )));
567 }
568 let source = image.as_raw();
569 let mut bgra = Vec::with_capacity(source.len());
570 for [r, g, b, a] in source.as_chunks::<4>().0 {
571 bgra.extend_from_slice(&[*b, *g, *r, *a]);
572 }
573 let buffer = CryptographicBuffer::CreateFromByteArray(&bgra).map_err(|error| {
574 ComputerError::ExecutionFailed(format!(
575 "CryptographicBuffer::CreateFromByteArray: {error}"
576 ))
577 })?;
578 SoftwareBitmap::CreateCopyWithAlphaFromBuffer(
579 &buffer,
580 BitmapPixelFormat::Bgra8,
581 width,
582 height,
583 BitmapAlphaMode::Premultiplied,
584 )
585 .map_err(|error| {
586 ComputerError::ExecutionFailed(format!(
587 "SoftwareBitmap::CreateCopyWithAlphaFromBuffer: {error}"
588 ))
589 })
590 }
591
592 struct MtaUsage {
594 cookie: CO_MTA_USAGE_COOKIE,
595 }
596
597 impl MtaUsage {
598 fn new() -> Result<Self, ComputerError> {
599 let cookie = unsafe { CoIncrementMTAUsage() }.map_err(|error| {
600 ComputerError::ExecutionFailed(format!("CoIncrementMTAUsage: {error}"))
601 })?;
602 Ok(Self { cookie })
603 }
604 }
605
606 impl Drop for MtaUsage {
607 fn drop(&mut self) {
608 let _ = unsafe { CoDecrementMTAUsage(self.cookie) };
609 }
610 }
611
612 struct Apartment {
614 initialized: bool,
615 }
616
617 impl Apartment {
618 fn new() -> Result<Self, ComputerError> {
619 match unsafe { RoInitialize(RO_INIT_MULTITHREADED) } {
620 Ok(()) => Ok(Self { initialized: true }),
621 Err(error) if error.code() == S_FALSE => Ok(Self { initialized: false }),
622 Err(error) => Err(ComputerError::ExecutionFailed(format!(
623 "RoInitialize(RO_INIT_MULTITHREADED) failed: {error}"
624 ))),
625 }
626 }
627 }
628
629 impl Drop for Apartment {
630 fn drop(&mut self) {
631 if self.initialized {
632 unsafe { RoUninitialize() };
633 }
634 }
635 }
636}
637
638#[cfg(test)]
639mod tests {
640 use super::*;
641
642 #[test]
648 fn ocr_box_passes_through_image_pixels() {
649 let rect = ocr_box_to_image(800, 600, 100.0, 50.0, 200.0, 30.0);
650 assert_eq!(
651 rect,
652 ImageRect {
653 x: 100,
654 y: 50,
655 width: 200,
656 height: 30
657 }
658 );
659 }
660
661 #[test]
662 fn ocr_box_keeps_the_top_left_origin() {
663 let rect = ocr_box_to_image(400, 400, 10.0, 20.0, 50.0, 15.0);
666 assert_eq!(rect.y, 20);
667 assert!(rect.y < 200, "{rect:?}");
668 }
669
670 #[test]
671 fn ocr_box_rounds_to_whole_pixels() {
672 let rect = ocr_box_to_image(800, 600, 10.4, 20.6, 30.5, 40.4);
675 assert_eq!(rect.x, 10);
676 assert_eq!(rect.y, 21);
677 assert_eq!(rect.width, 31);
678 assert_eq!(rect.height, 40);
679 }
680
681 #[test]
682 fn ocr_box_is_clamped_to_the_image() {
683 let rect = ocr_box_to_image(200, 100, 190.0, 90.0, 80.0, 80.0);
684 assert_eq!(rect.x, 190);
685 assert_eq!(rect.y, 90);
686 assert!(rect.x + rect.width as i32 <= 200, "{rect:?}");
687 assert!(rect.y + rect.height as i32 <= 100, "{rect:?}");
688 }
689
690 #[test]
691 fn ocr_box_clamps_a_negative_origin_to_zero() {
692 let rect = ocr_box_to_image(200, 100, -40.0, -10.0, 60.0, 20.0);
693 assert_eq!(rect.x, 0);
694 assert_eq!(rect.y, 0);
695 assert_eq!(rect.width, 20);
696 assert_eq!(rect.height, 10);
697 }
698
699 #[test]
700 fn ocr_box_is_never_empty() {
701 let rect = ocr_box_to_image(200, 100, 50.0, 50.0, 0.0, 0.0);
702 assert_eq!(rect.width, 1);
703 assert_eq!(rect.height, 1);
704 }
705
706 #[test]
707 fn ocr_box_feeds_the_screenshot_map_back_to_a_screen_point() {
708 let map = crate::image_util::ScreenshotMap {
709 width: 800,
710 height: 600,
711 scale: 1.0,
712 logical_width: 800,
713 logical_height: 600,
714 origin: crate::image_util::MapPoint { x: 120, y: 40 },
715 fit: "none".into(),
716 pad: crate::image_util::MapPoint { x: 0, y: 0 },
717 content_width: 800,
718 content_height: 600,
719 map: crate::image_util::MapFormulas {
720 image_to_screen: String::new(),
721 screen_to_image: String::new(),
722 },
723 };
724 let rect = ocr_box_to_image(800, 600, 100.0, 50.0, 200.0, 30.0);
725 let (screen_x, screen_y) = rect.to_screen(&map).expect("on screen");
726 assert!((screen_x - 320.0).abs() < 0.001, "{screen_x}");
727 assert!((screen_y - 105.0).abs() < 0.001, "{screen_y}");
728 }
729
730 fn block(x: i32, y: i32, w: u32, h: u32) -> OcrBlock {
731 OcrBlock {
732 text: "secret".into(),
733 x,
734 y,
735 width: w,
736 height: h,
737 confidence: 0.95,
738 }
739 }
740
741 fn rect(x: i32, y: i32, w: u32, h: u32) -> ImageRect {
742 ImageRect {
743 x,
744 y,
745 width: w,
746 height: h,
747 }
748 }
749
750 #[test]
751 fn redaction_drops_blocks_inside_secure_frame() {
752 let blocks = vec![block(10, 10, 50, 20), block(200, 200, 5, 5)];
753 let frames = vec![rect(0, 0, 100, 100)];
754 let kept = redact_blocks(blocks, frames);
755 assert_eq!(kept.len(), 1, "secure frame must drop the inner block");
756 assert_eq!((kept[0].x, kept[0].y), (200, 200));
757 }
758
759 #[test]
760 fn redaction_keeps_blocks_overlapping_an_edge() {
761 let blocks = vec![block(90, 90, 30, 30)];
762 let frames = vec![rect(0, 0, 100, 100)];
763 let kept = redact_blocks(blocks, frames);
764 assert_eq!(kept.len(), 1, "blocks that overflow the secure frame stay");
765 }
766
767 #[test]
768 fn redaction_with_no_frames_returns_all_blocks() {
769 let blocks = vec![block(0, 0, 10, 10), block(50, 50, 5, 5)];
770 let kept = redact_blocks(blocks, Vec::<ImageRect>::new());
771 assert_eq!(kept.len(), 2);
772 }
773
774 #[test]
775 #[cfg(target_os = "linux")]
776 fn linux_ocr_returns_unsupported() {
777 use image::RgbaImage;
778 let image = RgbaImage::from_pixel(8, 8, image::Rgba([255, 255, 255, 255]));
779 let map = make_stub_map();
780 let err = recognize(&image, &map).unwrap_err();
781 match err {
782 ComputerError::Unsupported(reason) => {
783 assert!(
784 reason.to_lowercase().contains("linux"),
785 "Linux unsupported message must mention linux: {reason}"
786 );
787 }
788 other => panic!("expected Unsupported, got {other:?}"),
789 }
790 }
791
792 #[test]
793 #[cfg(target_os = "windows")]
794 fn windows_media_ocr_runs_and_never_returns_unsupported() {
795 use image::RgbaImage;
804 let image = RgbaImage::from_pixel(64, 32, image::Rgba([255, 255, 255, 255]));
805 let map = make_stub_map();
806 match recognize(&image, &map) {
807 Ok(result) => {
808 assert!(
809 result.blocks.iter().all(|block| {
810 block.width >= 1 && block.height >= 1 && block.x >= 0 && block.y >= 0
811 }),
812 "blocks must be non-empty image rects: {:?}",
813 result.blocks
814 );
815 }
816 Err(ComputerError::Unsupported(_)) => {
817 panic!("Windows OCR must not be unsupported after P12b")
818 }
819 Err(ComputerError::ExecutionFailed(reason)) => assert!(
820 reason.contains("Windows.Media.Ocr"),
821 "a Windows OCR failure must name the engine: {reason}"
822 ),
823 Err(other) => panic!("unexpected error from Windows OCR: {other:?}"),
824 }
825 }
826
827 #[cfg(target_os = "macos")]
829 #[test]
830 fn vision_rect_flips_origin() {
831 use image::{Rgba, RgbaImage};
832 let image = RgbaImage::from_pixel(100, 80, Rgba([255, 255, 255, 255]));
833 let rect = super::VisionRect {
834 left: 0.0,
835 top: 1.0,
836 right: 1.0,
837 bottom: 0.0,
838 };
839 let image_rect = super::vision_box_to_image(&image, rect);
840 assert_eq!(image_rect.x, 0);
841 assert_eq!(image_rect.y, 0);
842 assert_eq!(image_rect.width, 100);
843 assert_eq!(image_rect.height, 80);
844 }
845
846 #[cfg(target_os = "macos")]
853 #[test]
854 #[ignore = "requires a live Vision framework; run locally with `cargo test --release vision_recognizes_drawn_text -- --ignored --nocapture`"]
855 fn vision_recognizes_drawn_text() {
856 use image::{Rgba, RgbaImage};
857
858 const FONT_W: usize = 5;
860 const FONT_H: usize = 7;
861 const FONT: &[(char, [u8; FONT_H])] = &[
862 (
863 'H',
864 [
865 0b10001, 0b10001, 0b10001, 0b11111, 0b10001, 0b10001, 0b10001,
866 ],
867 ),
868 (
869 'E',
870 [
871 0b11111, 0b10000, 0b10000, 0b11110, 0b10000, 0b10000, 0b11111,
872 ],
873 ),
874 (
875 'L',
876 [
877 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b10000, 0b11111,
878 ],
879 ),
880 (
881 'O',
882 [
883 0b01110, 0b10001, 0b10001, 0b10001, 0b10001, 0b10001, 0b01110,
884 ],
885 ),
886 (' ', [0, 0, 0, 0, 0, 0, 0]),
887 ];
888 fn put_pixel(image: &mut RgbaImage, x: i32, y: i32, color: Rgba<u8>) {
889 if x < 0 || y < 0 {
890 return;
891 }
892 let w = image.width() as i32;
893 let h = image.height() as i32;
894 if x >= w || y >= h {
895 return;
896 }
897 image.put_pixel(x as u32, y as u32, color);
898 }
899
900 let mut image = RgbaImage::from_pixel(200, 60, Rgba([255, 255, 255, 255]));
902 let ink = Rgba([0, 0, 0, 255]);
903 let scale = 4_u32;
904 let text = "HELLO";
905 let origin_x: i32 = 10;
906 let origin_y: i32 = 10;
907 let cell_w = (FONT_W as u32) * scale;
908 let cell_h = (FONT_H as u32) * scale;
909 let mut pen_x = origin_x;
910 for ch in text.chars() {
911 let glyph = match FONT.iter().find(|(key, _)| *key == ch) {
912 Some((_, g)) => *g,
913 None => {
914 pen_x += cell_w as i32;
915 continue;
916 }
917 };
918 for (row, byte) in glyph.iter().enumerate() {
919 for col in 0..FONT_W {
920 if (byte >> (FONT_W - 1 - col)) & 1 == 1 {
921 let px0 = pen_x + (col as i32) * scale as i32;
922 let py0 = origin_y + (row as i32) * scale as i32;
923 for dy in 0..scale as i32 {
924 for dx in 0..scale as i32 {
925 put_pixel(&mut image, px0 + dx, py0 + dy, ink);
926 }
927 }
928 }
929 }
930 }
931 pen_x += cell_w as i32;
932 if pen_x + cell_w as i32 > image.width() as i32 {
933 break;
934 }
935 if origin_y + cell_h as i32 > image.height() as i32 {
936 break;
937 }
938 }
939
940 let map = make_stub_map();
941 let result =
942 recognize(&image, &map).expect("recognize should not error on a synthetic image");
943
944 assert!(
945 !result.blocks.is_empty(),
946 "Vision must return at least one block for a drawn text buffer; got {:?}",
947 result
948 );
949 let joined: String = result
950 .blocks
951 .iter()
952 .map(|block| block.text.to_uppercase())
953 .collect::<Vec<_>>()
954 .join(" ");
955 assert!(
956 joined.contains("HELLO"),
957 "Vision output should contain HELLO; got: {joined:?}"
958 );
959 }
960
961 #[cfg(any(test, target_os = "linux", target_os = "windows"))]
962 #[allow(dead_code)]
963 fn make_stub_map() -> crate::image_util::ScreenshotMap {
964 crate::image_util::ScreenshotMap {
965 width: 8,
966 height: 8,
967 scale: 1.0,
968 logical_width: 8,
969 logical_height: 8,
970 origin: crate::image_util::MapPoint { x: 0, y: 0 },
971 fit: "none".into(),
972 pad: crate::image_util::MapPoint { x: 0, y: 0 },
973 content_width: 8,
974 content_height: 8,
975 map: crate::image_util::MapFormulas {
976 image_to_screen: String::new(),
977 screen_to_image: String::new(),
978 },
979 }
980 }
981}