Skip to content

Commit 79fe6ba

Browse files
committed
Add saliency detection-based crop
1 parent 8001d16 commit 79fe6ba

File tree

3 files changed

+248
-9
lines changed

3 files changed

+248
-9
lines changed
Lines changed: 172 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,172 @@
1+
import Collections
2+
import os
3+
import UIKit
4+
import Vision
5+
6+
private let logger = Logger(subsystem: "AsyncImageKit", category: "SaliencyService")
7+
8+
/// Detects the most salient (visually interesting) region in images using Vision framework.
9+
/// Results are cached by image URL.
10+
public actor SaliencyService {
11+
public nonisolated static let shared = SaliencyService()
12+
13+
private nonisolated let cache = SaliencyCache()
14+
private var inflightTasks: [URL: Task<CGRect?, Never>] = [:]
15+
16+
init() {
17+
Task.detached(priority: .utility) { [cache] in cache.loadFromDisk() }
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 let cached = cache.cachedRect(for: url) {
31+
return cached
32+
}
33+
if let existing = inflightTasks[url] {
34+
return await existing.value
35+
}
36+
let task = Task<CGRect?, Never> {
37+
await SaliencyService.detect(in: image)
38+
}
39+
inflightTasks[url] = task
40+
let result = await task.value
41+
inflightTasks[url] = nil
42+
if let result {
43+
cache.store(result, for: url)
44+
}
45+
return result
46+
}
47+
48+
/// Returns the frame for the image view within a container such that `saliencyRect`
49+
/// appears at `topInset` points from the top. Returns `nil` when no adjustment is needed
50+
/// (i.e. the image is not portrait relative to the container).
51+
public nonisolated func adjustedFrame(
52+
saliencyRect: CGRect,
53+
imageSize: CGSize,
54+
in containerSize: CGSize,
55+
topInset: CGFloat = 16
56+
) -> CGRect? {
57+
guard imageSize.width > 0, imageSize.height > 0,
58+
containerSize.width > 0, containerSize.height > 0 else { return nil }
59+
60+
let imageAspect = imageSize.width / imageSize.height
61+
let containerAspect = containerSize.width / containerSize.height
62+
63+
// Only adjust for portrait images shown in a wider container.
64+
guard imageAspect < containerAspect else { return nil }
65+
66+
// Scale to fill container width; the scaled height will exceed container height.
67+
let scale = containerSize.width / imageSize.width
68+
let scaledHeight = imageSize.height * scale
69+
70+
let salientTopInScaled = saliencyRect.origin.y * scaledHeight
71+
let desiredY = topInset - salientTopInScaled
72+
73+
// Clamp so the image always covers the full container without empty gaps.
74+
let minY = containerSize.height - scaledHeight // negative
75+
let clampedY = min(0, max(minY, desiredY))
76+
77+
return CGRect(x: 0, y: clampedY, width: containerSize.width, height: scaledHeight)
78+
}
79+
80+
private static func detect(in image: UIImage) async -> CGRect? {
81+
guard let cgImage = image.cgImage else { return nil }
82+
return await Task.detached(priority: .userInitiated) {
83+
let request = VNGenerateObjectnessBasedSaliencyImageRequest()
84+
let handler = VNImageRequestHandler(cgImage: cgImage, options: [:])
85+
do {
86+
try handler.perform([request])
87+
} catch {
88+
return nil
89+
}
90+
guard let observation = request.results?.first,
91+
let salientObjects = observation.salientObjects,
92+
!salientObjects.isEmpty else {
93+
return nil
94+
}
95+
// Union all salient object bounding boxes.
96+
// Vision coordinates: origin at bottom-left, Y increases upward.
97+
let union = salientObjects.reduce(CGRect.null) { $0.union($1.boundingBox) }
98+
// Convert to UIKit coordinates (origin at top-left, Y increases downward).
99+
return CGRect(
100+
x: union.origin.x,
101+
y: 1.0 - union.origin.y - union.height,
102+
width: union.width,
103+
height: union.height
104+
)
105+
}.value
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 cachedRect(for url: URL) -> CGRect? {
137+
lock.withLock { store[url.absoluteString] }
138+
}
139+
140+
func store(_ rect: CGRect, for url: URL) {
141+
lock.withLock {
142+
let key = url.absoluteString
143+
store.updateValue(rect, forKey: key)
144+
if store.count > Self.maxCount, let oldest = store.keys.first {
145+
store.removeValue(forKey: oldest)
146+
}
147+
isDirty = true
148+
}
149+
}
150+
151+
func loadFromDisk() {
152+
guard let data = try? Data(contentsOf: Self.diskURL),
153+
let decoded = try? JSONDecoder().decode([String: CGRect].self, from: data) else {
154+
return
155+
}
156+
lock.withLock {
157+
store = OrderedDictionary(uniqueKeysWithValues: decoded.map { ($0.key, $0.value) })
158+
}
159+
}
160+
161+
func saveToDisk() {
162+
let snapshot: OrderedDictionary<String, CGRect>? = lock.withLock {
163+
guard isDirty else { return nil }
164+
isDirty = false
165+
return store
166+
}
167+
guard let snapshot else { return }
168+
let dict = snapshot.reduce(into: [String: CGRect]()) { $0[$1.key] = $1.value }
169+
guard let data = try? JSONEncoder().encode(dict) else { return }
170+
try? data.write(to: Self.diskURL, options: .atomic)
171+
}
172+
}

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

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)