Skip to content

Commit 7512ee3

Browse files
committed
Add saliency detection-based crop
1 parent 8001d16 commit 7512ee3

4 files changed

Lines changed: 248 additions & 9 deletions

File tree

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

Modules/Sources/AsyncImageKit/Views/AsyncImageView.swift

Lines changed: 75 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,31 @@ 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+
imageView.frame = saliencyAdjustedFrame()
100+
}
101+
81102
/// Removes the current image and stops the outstanding downloads.
82103
public func prepareForReuse() {
83104
controller.prepareForReuse()
84105
image = nil
106+
saliencyRect = nil
107+
currentImageURL = nil
108+
saliencyTask?.cancel()
109+
saliencyTask = nil
85110
}
86111

87112
/// - parameter size: Target image size in pixels.
@@ -90,11 +115,13 @@ public final class AsyncImageView: UIView {
90115
host: MediaHostProtocol? = nil,
91116
size: ImageSize? = nil
92117
) {
118+
currentImageURL = imageURL
93119
let request = ImageRequest(url: imageURL, host: host, options: ImageRequestOptions(size: size))
94120
controller.setImage(with: request)
95121
}
96122

97123
public func setImage(with request: ImageRequest, completion: (@MainActor (Result<UIImage, Error>) -> Void)? = nil) {
124+
currentImageURL = request.source.url
98125
controller.setImage(with: request, completion: completion)
99126
}
100127

@@ -113,15 +140,54 @@ public final class AsyncImageView: UIView {
113140
}
114141
case .success(let image):
115142
self.image = image
116-
imageView.isHidden = false
117-
backgroundColor = .clear
143+
let needsDetection = isSaliencyDetectionEnabled
144+
&& !(isSaliencyPortraitOnly && image.size.width >= image.size.height)
145+
if needsDetection, let url = currentImageURL {
146+
if let cached = SaliencyService.shared.cachedSaliencyRect(for: url) {
147+
saliencyRect = cached
148+
imageView.isHidden = false
149+
backgroundColor = .clear
150+
} else {
151+
triggerSaliencyDetection(image: image, url: url)
152+
}
153+
} else {
154+
imageView.isHidden = false
155+
backgroundColor = .clear
156+
}
118157
case .failure:
119158
if configuration.isErrorViewEnabled {
120159
makeErrorView().isHidden = false
121160
}
122161
}
123162
}
124163

164+
private func triggerSaliencyDetection(image: UIImage, url: URL) {
165+
saliencyTask = Task { @MainActor [weak self] in
166+
guard let self else { return }
167+
let rect = await SaliencyService.shared.saliencyRect(for: image, url: url)
168+
guard !Task.isCancelled else { return }
169+
// Reveal the image only after saliency detection finishes (with or without a result).
170+
self.saliencyRect = rect
171+
self.imageView.isHidden = false
172+
self.backgroundColor = .clear
173+
}
174+
}
175+
176+
// MARK: - Frame Calculation
177+
178+
private func saliencyAdjustedFrame() -> CGRect {
179+
guard isSaliencyDetectionEnabled, let image, let saliencyRect else {
180+
return bounds
181+
}
182+
return SaliencyService.shared.adjustedFrame(
183+
saliencyRect: saliencyRect,
184+
imageSize: image.size,
185+
in: bounds.size
186+
) ?? bounds
187+
}
188+
189+
// MARK: - Helpers
190+
125191
private func didUpdateConfiguration(_ configuration: Configuration) {
126192
if let tintColor = configuration.tintColor {
127193
imageView.tintColor = tintColor

RELEASE-NOTES.txt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
-----
33
* [*] All self-hosted sites now sign in using application passwords [#25424]
44
* [*] Reader: Fix button style [#25447]
5+
* [*] Reader: Add smart cropping for featured images in the feed so it never cut the heads off [#25451]
56

67

78
26.8

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)