/* * Copyright (C) 2021-2022 Apple Inc. All rights reserved. * * Redistribution and use in source and binary forms, with or without * modification, are permitted provided that the following conditions * are met: * 1. Redistributions of source code must retain the above copyright * notice, this list of conditions and the following disclaimer. * 2. Redistributions in binary form must reproduce the above copyright * notice, this list of conditions and the following disclaimer in the * documentation and/or other materials provided with the distribution. * * THIS SOFTWARE IS PROVIDED BY APPLE INC. AND ITS CONTRIBUTORS ``AS IS'' * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, * THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR * PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL APPLE INC. OR ITS CONTRIBUTORS * BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF * THE POSSIBILITY OF SUCH DAMAGE. */ #import "config.h" #import "ImageAnalysisUtilities.h" #if ENABLE(IMAGE_ANALYSIS) #import "CocoaImage.h" #import "Logging.h" #import "TransactionID.h" #import #import #import #import #if HAVE(UNIFORM_TYPE_IDENTIFIERS_FRAMEWORK) #import #endif #import namespace WebKit { using namespace WebCore; RetainPtr createImageAnalyzer() { #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) return adoptNS([PAL::allocVKCImageAnalyzerInstance() init]); #else return adoptNS([PAL::allocVKImageAnalyzerInstance() init]); #endif } RetainPtr createImageAnalyzerRequest(CGImageRef image, VKAnalysisTypes types) { #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) return adoptNS([(PAL::allocVKCImageAnalyzerRequestInstance()) initWithCGImage:image orientation:VKImageOrientationUp requestType:types]); #else return adoptNS([PAL::allocVKImageAnalyzerRequestInstance() initWithCGImage:image orientation:VKImageOrientationUp requestType:types]); #endif } static FloatQuad floatQuad(VKQuad *quad) { return { quad.topLeft, quad.topRight, quad.bottomRight, quad.bottomLeft }; } static Vector floatQuads(NSArray *vkQuads) { Vector quads; quads.reserveInitialCapacity(vkQuads.count); for (VKQuad *vkQuad in vkQuads) quads.uncheckedAppend(floatQuad(vkQuad)); return quads; } TextRecognitionResult makeTextRecognitionResult(CocoaImageAnalysis *analysis) { NSArray *allLines = analysis.allLines; TextRecognitionResult result; result.lines.reserveInitialCapacity(allLines.count); bool isFirstLine = true; size_t nextLineIndex = 1; for (VKWKLineInfo *line in allLines) { Vector children; NSArray *vkChildren = line.children; children.reserveInitialCapacity(vkChildren.count); String lineText = line.string; unsigned searchLocation = 0; for (VKWKTextInfo *child in vkChildren) { if (searchLocation >= lineText.length()) { ASSERT_NOT_REACHED(); continue; } String childText = child.string; auto matchLocation = lineText.find(childText, searchLocation); if (matchLocation == notFound) { ASSERT_NOT_REACHED(); continue; } bool hasLeadingWhitespace = ([&] { if (matchLocation == searchLocation) return !isFirstLine && !searchLocation; auto textBeforeMatch = StringView(lineText).substring(searchLocation, matchLocation - searchLocation); return !textBeforeMatch.isEmpty() && isSpaceOrNewline(textBeforeMatch[0]); })(); searchLocation = matchLocation + childText.length(); children.uncheckedAppend({ WTFMove(childText), floatQuad(child.quad), hasLeadingWhitespace }); } VKWKLineInfo *nextLine = nextLineIndex < allLines.count ? allLines[nextLineIndex] : nil; // The `shouldWrap` property indicates whether or not a line should wrap, relative to the previous line. bool hasTrailingNewline = nextLine && (![nextLine respondsToSelector:@selector(shouldWrap)] || ![nextLine shouldWrap]); result.lines.uncheckedAppend({ floatQuad(line.quad), WTFMove(children), hasTrailingNewline }); isFirstLine = false; nextLineIndex++; } #if ENABLE(DATA_DETECTION) if ([analysis respondsToSelector:@selector(textDataDetectors)]) { auto dataDetectors = RetainPtr { analysis.textDataDetectors }; result.dataDetectors.reserveInitialCapacity([dataDetectors count]); for (VKWKDataDetectorInfo *info in dataDetectors.get()) result.dataDetectors.uncheckedAppend({ info.result, floatQuads(info.boundingQuads) }); } #endif // ENABLE(DATA_DETECTION) #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) if ([analysis isKindOfClass:PAL::getVKCImageAnalysisClass()]) result.platformData = analysis; #endif return result; } #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) static String languageCodeForLocale(NSString *localeIdentifier) { return [NSLocale localeWithLocaleIdentifier:localeIdentifier].languageCode; } #endif bool languageIdentifierSupportsLiveText(NSString *languageIdentifier) { #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) auto languageCode = languageCodeForLocale(languageIdentifier); if (languageCode.isEmpty()) return true; static NeverDestroyed> supportedLanguages = [] { MemoryCompactRobinHoodHashSet set; for (NSString *identifier in [PAL::getVKCImageAnalyzerClass() supportedRecognitionLanguages]) { if (auto code = languageCodeForLocale(identifier); !code.isEmpty()) set.add(WTFMove(code)); } return set; }(); return supportedLanguages->contains(languageCode); #else UNUSED_PARAM(languageIdentifier); return true; #endif } #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) static TextRecognitionResult makeTextRecognitionResult(VKCImageAnalysisTranslation *translation, TransactionID transactionID) { TextRecognitionResult result; NSArray *paragraphs = translation.paragraphs; result.blocks.reserveInitialCapacity(paragraphs.count); for (VKCTranslatedParagraph *paragraph in paragraphs) { if (!paragraph.text.length) { RELEASE_LOG(Translation, "[#%{public}s] Skipping empty translation paragraph", transactionID.loggingString().utf8().data()); continue; } if ([paragraph respondsToSelector:@selector(isPassthrough)] && [paragraph isPassthrough]) continue; auto quad = floatQuad(paragraph.quad); if (quad.p2().x() > quad.p4().x() && quad.p2().y() > quad.p4().y() && quad.p1().x() < quad.p3().x() && quad.p1().y() > quad.p3().y()) { // The entire quad is flipped about the x-axis. Maintain valid positions for each of the quad points // by flipping each quad point, such that the top left and bottom right positions are positioned correctly. quad.setP1({ quad.p1().x(), 1 - quad.p1().y() }); quad.setP2({ quad.p2().x(), 1 - quad.p2().y() }); quad.setP3({ quad.p3().x(), 1 - quad.p3().y() }); quad.setP4({ quad.p4().x(), 1 - quad.p4().y() }); } result.blocks.uncheckedAppend({ paragraph.text, WTFMove(quad) }); } return result; } static bool shouldLogFullImageTranslationResults() { static std::once_flag onceFlag; static bool shouldLog = false; std::call_once(onceFlag, [&] { shouldLog = [NSUserDefaults.standardUserDefaults boolForKey:@"WebKitLogFullImageTranslationResults"]; }); return shouldLog; } void requestVisualTranslation(CocoaImageAnalyzer *analyzer, NSURL *imageURL, const String& sourceLocale, const String& targetLocale, CGImageRef image, CompletionHandler&& completion) { auto startTime = MonotonicTime::now(); static TransactionID imageAnalysisRequestID; auto currentRequestID = imageAnalysisRequestID.increment(); if (shouldLogFullImageTranslationResults()) RELEASE_LOG(Translation, "[#%{public}s] Image translation started for %{private}@", currentRequestID.loggingString().utf8().data(), imageURL); else RELEASE_LOG(Translation, "[#%{public}s] Image translation started", currentRequestID.loggingString().utf8().data()); auto request = createImageAnalyzerRequest(image, VKAnalysisTypeText); [analyzer processRequest:request.get() progressHandler:nil completionHandler:makeBlockPtr([completion = WTFMove(completion), sourceLocale, targetLocale, currentRequestID, startTime] (CocoaImageAnalysis *analysis, NSError *analysisError) mutable { callOnMainRunLoop([completion = WTFMove(completion), analysis = RetainPtr { analysis }, analysisError = RetainPtr { analysisError }, sourceLocale, targetLocale, currentRequestID, startTime] () mutable { auto imageAnalysisDelay = MonotonicTime::now() - startTime; if (!analysis) { RELEASE_LOG(Translation, "[#%{public}s] Image translation failed in %.3f sec. (error: %{public}@)", currentRequestID.loggingString().utf8().data(), imageAnalysisDelay.seconds(), analysisError.get()); return completion({ }); } if (![analysis hasResultsForAnalysisTypes:VKAnalysisTypeText]) { RELEASE_LOG(Translation, "[#%{public}s] Image translation completed in %.3f sec. (no text)", currentRequestID.loggingString().utf8().data(), imageAnalysisDelay.seconds()); return completion({ }); } auto allLines = [analysis allLines]; if (shouldLogFullImageTranslationResults()) { StringBuilder stringToLog; bool firstLine = true; for (VKWKLineInfo *info in allLines) { if (!firstLine) stringToLog.append("\\n"_s); stringToLog.append(String { info.string }); firstLine = false; } RELEASE_LOG(Translation, "[#%{public}s] Image translation recognized text in %.3f sec. (line count: %zu): \"%{private}s\"", currentRequestID.loggingString().utf8().data(), imageAnalysisDelay.seconds(), allLines.count, stringToLog.toString().utf8().data()); } else RELEASE_LOG(Translation, "[#%{public}s] Image translation recognized text in %.3f sec. (line count: %zu)", currentRequestID.loggingString().utf8().data(), imageAnalysisDelay.seconds(), allLines.count); auto translationStartTime = MonotonicTime::now(); auto completionBlock = makeBlockPtr([completion = WTFMove(completion), currentRequestID, translationStartTime](VKCImageAnalysisTranslation *translation, NSError *error) mutable { auto translationDelay = MonotonicTime::now() - translationStartTime; if (error) { RELEASE_LOG(Translation, "[#%{public}s] Image translation failed in %.3f sec. (error: %{public}@)", currentRequestID.loggingString().utf8().data(), translationDelay.seconds(), error); return completion({ }); } if (shouldLogFullImageTranslationResults()) { StringBuilder stringToLog; bool firstLine = true; for (VKCTranslatedParagraph *paragraph in translation.paragraphs) { if (!firstLine) stringToLog.append("\\n"_s); stringToLog.append(String { paragraph.text }); firstLine = false; } RELEASE_LOG(Translation, "[#%{public}s] Image translation completed in %.3f sec. (paragraph count: %zu): \"%{private}s\"", currentRequestID.loggingString().utf8().data(), translationDelay.seconds(), translation.paragraphs.count, stringToLog.toString().utf8().data()); } else RELEASE_LOG(Translation, "[#%{public}s] Image translation completed in %.3f sec. (paragraph count: %zu)", currentRequestID.loggingString().utf8().data(), translationDelay.seconds(), translation.paragraphs.count); completion(makeTextRecognitionResult(translation, currentRequestID)); }); if ([analysis respondsToSelector:@selector(translateFrom:to:withCompletion:)]) [analysis translateFrom:sourceLocale to:targetLocale withCompletion:completionBlock.get()]; else [analysis translateTo:targetLocale withCompletion:completionBlock.get()]; }); }).get()]; } void requestBackgroundRemoval(CGImageRef image, CompletionHandler&& completion) { if (!PAL::canLoad_VisionKitCore_vk_cgImageRemoveBackground()) { completion(nullptr, CGRectZero); return; } // FIXME (rdar://88834023): We should find a way to avoid this extra transcoding. auto tiffData = transcode(image, (__bridge CFStringRef)UTTypeTIFF.identifier); if (![tiffData length]) { completion(nullptr, CGRectZero); return; } auto transcodedImageSource = adoptCF(CGImageSourceCreateWithData((__bridge CFDataRef)tiffData.get(), nullptr)); auto transcodedImage = adoptCF(CGImageSourceCreateImageAtIndex(transcodedImageSource.get(), 0, nullptr)); if (!transcodedImage) { completion(nullptr, CGRectZero); return; } auto sourceImageSize = CGSizeMake(CGImageGetWidth(transcodedImage.get()), CGImageGetHeight(transcodedImage.get())); if (!sourceImageSize.width || !sourceImageSize.height) { completion(nullptr, CGRectZero); return; } PAL::softLinkVisionKitCorevk_cgImageRemoveBackground(transcodedImage.get(), YES, makeBlockPtr([sourceImageSize, completion = WTFMove(completion)](CGImageRef result, CGRect cropRect, NSError *error) mutable { if (error) RELEASE_LOG(ImageAnalysis, "Remove background failed with error: %@", error); callOnMainRunLoop([sourceImageSize, cropRect, protectedResult = RetainPtr { result }, completion = WTFMove(completion)]() mutable { FloatRect normalizedCropRect = cropRect; normalizedCropRect.scale(1 / sourceImageSize.width, 1 / sourceImageSize.height); completion(protectedResult.get(), normalizedCropRect); }); }).get()); } void setUpAdditionalImageAnalysisBehaviors(PlatformImageAnalysisObject *interactionOrView) { interactionOrView.activeInteractionTypes = VKImageAnalysisInteractionTypeTextSelection | VKImageAnalysisInteractionTypeDataDetectors; interactionOrView.wantsAutomaticContentsRectCalculation = NO; interactionOrView.actionInfoLiveTextButtonDisabled = NO; interactionOrView.actionInfoQuickActionsDisabled = NO; [interactionOrView setActionInfoViewHidden:NO animated:YES]; } #endif // ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) bool isLiveTextAvailableAndEnabled() { return PAL::isVisionKitCoreFrameworkAvailable(); } #if ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) std::pair, RetainPtr> imageDataForRemoveBackground(CGImageRef image, const String& sourceMIMEType) { static NeverDestroyed allowedMIMETypesForTranscoding = [] { static constexpr std::array types { "image/png"_s, "image/tiff"_s, "image/gif"_s, "image/bmp"_s }; MemoryCompactLookupOnlyRobinHoodHashSet set; set.reserveInitialCapacity(std::size(types)); for (auto type : types) set.add(type); return set; }(); if (!sourceMIMEType.isEmpty() && allowedMIMETypesForTranscoding->contains(sourceMIMEType)) return transcodeWithPreferredMIMEType(image, sourceMIMEType.createCFString().get()); return transcodeWithPreferredMIMEType(image, CFSTR("image/png")); } #endif // ENABLE(IMAGE_ANALYSIS_ENHANCEMENTS) } // namespace WebKit #endif // ENABLE(IMAGE_ANALYSIS)