Skip to content

Commit c574ef1

Browse files
authored
Reader: Add saliency detection for smart cropping (#25451)
* Add saliency detection-based crop * Improve SaliencyService * Rename SaliencyService * Cleanup * use VNGenerateAttentionBasedSaliencyImageRequest
1 parent 372fc10 commit c574ef1

4 files changed

Lines changed: 250 additions & 9 deletions

File tree

Lines changed: 176 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,176 @@
1+
import Collections
2+
import UIKit
3+
import Vision
4+
5+
/// Detects the most salient (visually interesting) region in images using Vision framework.
6+
/// Results are cached by image URL.
7+
public actor ImageSaliencyService {
8+
public nonisolated static let shared = ImageSaliencyService()
9+
10+
private nonisolated let cache = SaliencyCache()
11+
private nonisolated let detector = SaliencyDetector()
12+
private var inflightTasks: [URL: Task<CGRect?, Never>] = [:]
13+
14+
init() {
15+
Task { [cache] in
16+
cache.loadFromDisk()
17+
}
18+
}
19+
20+
/// Returns a cached rect synchronously without starting a task, or `nil` if not yet cached.
21+
public nonisolated func cachedSaliencyRect(for url: URL) -> CGRect? {
22+
cache.cachedRect(for: url)
23+
}
24+
25+
/// Returns the bounding rect of the most salient region in UIKit normalized coordinates
26+
/// (origin top-left, values 0–1), or `nil` if detection fails or no salient objects are found.
27+
///
28+
/// - warning: The underlying `Vision` framework works _only_ on the device.
29+
public func saliencyRect(for image: UIImage, url: URL) async -> CGRect? {
30+
if cache.isCached(for: url) {
31+
return cache.cachedRect(for: url)
32+
}
33+
if let existing = inflightTasks[url] {
34+
return await existing.value
35+
}
36+
let task = Task<CGRect?, Never> { [detector] in
37+
await detector.detect(in: image)
38+
}
39+
inflightTasks[url] = task
40+
let result = await task.value
41+
inflightTasks[url] = nil
42+
cache.store(result, for: url)
43+
return result
44+
}
45+
46+
/// Returns the frame for the image view within a container such that `saliencyRect`
47+
/// appears at `topInset` points from the top. Returns `nil` when no adjustment is needed
48+
/// (i.e. the image is not portrait relative to the container).
49+
public nonisolated func adjustedFrame(
50+
saliencyRect: CGRect,
51+
imageSize: CGSize,
52+
in containerSize: CGSize,
53+
topInset: CGFloat = 16
54+
) -> CGRect? {
55+
guard imageSize.width > 0, imageSize.height > 0,
56+
containerSize.width > 0, containerSize.height > 0 else { return nil }
57+
58+
let imageAspect = imageSize.width / imageSize.height
59+
let containerAspect = containerSize.width / containerSize.height
60+
61+
// Only adjust for portrait images shown in a wider container.
62+
guard imageAspect < containerAspect else { return nil }
63+
64+
// Scale to fill container width; the scaled height will exceed container height.
65+
let scale = containerSize.width / imageSize.width
66+
let scaledHeight = imageSize.height * scale
67+
68+
let salientTopInScaled = saliencyRect.origin.y * scaledHeight
69+
let desiredY = topInset - salientTopInScaled
70+
71+
// Clamp so the image always covers the full container without empty gaps.
72+
let minY = containerSize.height - scaledHeight // negative
73+
let clampedY = min(0, max(minY, desiredY))
74+
75+
return CGRect(x: 0, y: clampedY, width: containerSize.width, height: scaledHeight)
76+
}
77+
78+
}
79+
80+
/// Runs saliency detection serially — one image at a time.
81+
private actor SaliencyDetector {
82+
func detect(in image: UIImage) -> CGRect? {
83+
guard let cgImage = image.cgImage else { return nil }
84+
let request = VNGenerateAttentionBasedSaliencyImageRequest()
85+
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
86+
do {
87+
try handler.perform([request])
88+
} catch {
89+
return nil
90+
}
91+
guard let observation = request.results?.first,
92+
let salientObjects = observation.salientObjects,
93+
!salientObjects.isEmpty else {
94+
return nil
95+
}
96+
// Union all salient object bounding boxes.
97+
// Vision coordinates: origin at bottom-left, Y increases upward.
98+
let union = salientObjects.reduce(CGRect.null) { $0.union($1.boundingBox) }
99+
// Convert to UIKit coordinates (origin at top-left, Y increases downward).
100+
return CGRect(
101+
x: union.origin.x,
102+
y: 1.0 - union.origin.y - union.height,
103+
width: union.width,
104+
height: union.height
105+
)
106+
}
107+
}
108+
109+
private final class SaliencyCache: @unchecked Sendable {
110+
private var store: OrderedDictionary<String, CGRect?> = [:]
111+
private let lock = NSLock()
112+
private var isDirty = false
113+
private var observer: AnyObject?
114+
115+
private static let maxCount = 1000
116+
private static let diskURL: URL = {
117+
let caches = FileManager.default.urls(for: .cachesDirectory, in: .userDomainMask)[0]
118+
return caches.appendingPathComponent("saliency_cache.json")
119+
}()
120+
121+
init() {
122+
observer = NotificationCenter.default.addObserver(
123+
forName: UIApplication.didEnterBackgroundNotification,
124+
object: nil,
125+
queue: .main
126+
) { [weak self] _ in
127+
guard let self else { return }
128+
Task.detached(priority: .utility) { self.saveToDisk() }
129+
}
130+
}
131+
132+
deinit {
133+
if let observer { NotificationCenter.default.removeObserver(observer) }
134+
}
135+
136+
func isCached(for url: URL) -> Bool {
137+
lock.withLock { store[url.absoluteString] != nil }
138+
}
139+
140+
func cachedRect(for url: URL) -> CGRect? {
141+
lock.withLock { store[url.absoluteString] ?? nil }
142+
}
143+
144+
func store(_ rect: CGRect?, for url: URL) {
145+
lock.withLock {
146+
let key = url.absoluteString
147+
store.updateValue(rect, forKey: key)
148+
if store.count > Self.maxCount, let oldest = store.keys.first {
149+
store.removeValue(forKey: oldest)
150+
}
151+
isDirty = true
152+
}
153+
}
154+
155+
func loadFromDisk() {
156+
guard let data = try? Data(contentsOf: Self.diskURL),
157+
let decoded = try? JSONDecoder().decode([String: CGRect?].self, from: data) else {
158+
return
159+
}
160+
lock.withLock {
161+
store = OrderedDictionary(uniqueKeysWithValues: decoded.map { ($0.key, $0.value) })
162+
}
163+
}
164+
165+
func saveToDisk() {
166+
let snapshot: OrderedDictionary<String, CGRect?>? = lock.withLock {
167+
guard isDirty else { return nil }
168+
isDirty = false
169+
return store
170+
}
171+
guard let snapshot else { return }
172+
let dict = snapshot.reduce(into: [String: CGRect?]()) { $0[$1.key] = $1.value }
173+
guard let data = try? JSONEncoder().encode(dict) else { return }
174+
try? data.write(to: Self.diskURL, options: .atomic)
175+
}
176+
}

Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift

Lines changed: 72 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,25 @@ public final class AsyncImageView: UIView {
1010
private var spinner: UIActivityIndicatorView?
1111
private let controller = ImageLoadingController()
1212

13+
// MARK: - Saliency
14+
15+
/// When enabled, detects the most visually interesting region of portrait images
16+
/// and adjusts the crop so that region appears near the top of the container.
17+
public var isSaliencyDetectionEnabled = false
18+
19+
/// When `true`, saliency detection only runs for images whose height exceeds their
20+
/// width (portrait images). Landscape and square images are displayed immediately
21+
/// without blocking on detection. Default is `true`.
22+
public var isSaliencyPortraitOnly = true
23+
24+
private var currentImageURL: URL?
25+
private var saliencyTask: Task<Void, Never>?
26+
private var saliencyRect: CGRect? {
27+
didSet { setNeedsLayout() }
28+
}
29+
30+
// MARK: - Configuration
31+
1332
public enum LoadingStyle {
1433
/// Shows a secondary background color during the download.
1534
case background
@@ -63,25 +82,41 @@ public final class AsyncImageView: UIView {
6382
controller.onStateChanged = { [weak self] in self?.setState($0) }
6483

6584
addSubview(imageView)
66-
imageView.translatesAutoresizingMaskIntoConstraints = false
67-
NSLayoutConstraint.activate([
68-
imageView.topAnchor.constraint(equalTo: topAnchor),
69-
imageView.trailingAnchor.constraint(equalTo: trailingAnchor),
70-
imageView.bottomAnchor.constraint(equalTo: bottomAnchor),
71-
imageView.leadingAnchor.constraint(equalTo: leadingAnchor),
72-
])
85+
imageView.translatesAutoresizingMaskIntoConstraints = true
86+
imageView.autoresizingMask = []
87+
imageView.frame = bounds
7388

7489
imageView.clipsToBounds = true
7590
imageView.contentMode = .scaleAspectFill
7691
imageView.accessibilityIgnoresInvertColors = true
7792

93+
clipsToBounds = true
7894
backgroundColor = .secondarySystemBackground
7995
}
8096

97+
public override func layoutSubviews() {
98+
super.layoutSubviews()
99+
100+
imageView.frame = {
101+
guard isSaliencyDetectionEnabled, let image, let saliencyRect else {
102+
return bounds
103+
}
104+
return ImageSaliencyService.shared.adjustedFrame(
105+
saliencyRect: saliencyRect,
106+
imageSize: image.size,
107+
in: bounds.size
108+
) ?? bounds
109+
}()
110+
}
111+
81112
/// Removes the current image and stops the outstanding downloads.
82113
public func prepareForReuse() {
83114
controller.prepareForReuse()
84115
image = nil
116+
saliencyRect = nil
117+
currentImageURL = nil
118+
saliencyTask?.cancel()
119+
saliencyTask = nil
85120
}
86121

87122
/// - parameter size: Target image size in pixels.
@@ -90,11 +125,13 @@ public final class AsyncImageView: UIView {
90125
host: MediaHostProtocol? = nil,
91126
size: ImageSize? = nil
92127
) {
128+
currentImageURL = imageURL
93129
let request = ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size))
94130
controller.setImage(with: request)
95131
}
96132

97133
public func setImage(with request: ImageRequest, completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil) {
134+
currentImageURL = request.source.url
98135
controller.setImage(with: request, completion: completion)
99136
}
100137

@@ -113,15 +150,41 @@ public final class AsyncImageView: UIView {
113150
}
114151
case .success(let image):
115152
self.image = image
116-
imageView.isHidden = false
117-
backgroundColor = .clear
153+
let needsDetection = isSaliencyDetectionEnabled
154+
&& !(isSaliencyPortraitOnly && image.size.width >= image.size.height)
155+
if needsDetection, let url = currentImageURL {
156+
if let cached = ImageSaliencyService.shared.cachedSaliencyRect(for: url) {
157+
saliencyRect = cached
158+
imageView.isHidden = false
159+
backgroundColor = .clear
160+
} else {
161+
triggerSaliencyDetection(image: image, url: url)
162+
}
163+
} else {
164+
imageView.isHidden = false
165+
backgroundColor = .clear
166+
}
118167
case .failure:
119168
if configuration.isErrorViewEnabled {
120169
makeErrorView().isHidden = false
121170
}
122171
}
123172
}
124173

174+
private func triggerSaliencyDetection(image: UIImage, url: URL) {
175+
saliencyTask = Task { @MainActor [weak self] in
176+
guard let self else { return }
177+
let rect = await ImageSaliencyService.shared.saliencyRect(for: image, url: url)
178+
guard !Task.isCancelled else { return }
179+
// Reveal the image only after saliency detection finishes (with or without a result).
180+
self.saliencyRect = rect
181+
self.imageView.isHidden = false
182+
self.backgroundColor = .clear
183+
}
184+
}
185+
186+
// MARK: - Helpers
187+
125188
private func didUpdateConfiguration(_ configuration: Configuration) {
126189
if let tintColor = configuration.tintColor {
127190
imageView.tintColor = tintColor

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@
55
* [*] Stats: Add a confirmation dialog when marking referrers as spam [#25475]
66
* [*] Reader: Fix an issue with Article screen not updating comments if comment is posted from inline section [#25483]
77
* [*] Reader: Fix button style [#25447]
8+
* [*] Reader: Add smart cropping for featured images in the feed so it never cut the heads off [#25451]
89
* [*] Reader: Fix separator not visible in Dark Mode for comments [#25466]
910
* [*] Reader: Fix an issue with "Notificaton Settings" showing incorrect state right after subscribing [#25459]
1011
* [*] Reader: Fix an issue with "Notificaton Settings" button shown when you are not subscribed [#25459]

WordPress/Classes/ViewRelated/Reader/Cards/ReaderPostCell.swift

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -143,6 +143,7 @@ private final class ReaderPostCellView: UIView {
143143
imageView.layer.cornerRadius = 8
144144
imageView.layer.masksToBounds = true
145145
imageView.contentMode = .scaleAspectFill
146+
imageView.isSaliencyDetectionEnabled = true
146147

147148
buttonMore.configuration?.baseForegroundColor = UIColor.secondaryLabel.withAlphaComponent(0.5)
148149
buttonMore.configuration?.contentInsets = .init(top: 12, leading: 8, bottom: 12, trailing: 20)

0 commit comments

Comments
 (0)