이미지 다운샘플링, 썸네일 분리 - Team-HGD/SniffMEET GitHub Wiki
작성자: 최진원
개요
앱 내부 메모리와 네트워크 사용량을 절약하기 위해 고해상도 이미지를 앱에서 요구하는 수준만 만족할 수 있도록 다운샘플링을 한다.
또한 메이트의 목록을 확인할 때, 프로필 이미지 전체가 필요하지 않고 썸네일 이미지 정도만 있어도 충분하므로, 프로필 이미지와 썸네일 이미지를 분리하려 한다.
팀 내부에서 사용할 다운 샘플링 기법은 다음과 같다:
- 이미지 포맷 변환하기
- RAW, HEIF, PNG와 같이 용량이 높은 포맷을 JPEG와 같은 손실 압축 포맷으로 변경하여 이미지 사이즈를 줄인다.
- 이미지 캔버스 사이즈 축소하기
- 고 해상도 이미지를 앱에서 요구하는 해상도에 맞게 변경한다.
주로 사용할 이미지 포맷
- HEIC: 주로 아이폰에서 설정 안 만지고 직접 촬영한 경우, 고 해상도 이미지도 용량이 적음
- DNG(HEIF): Pro RAW를 사용할 때 저장되는 포맷, HEIF는 proRAW 전용은 아니지만, proRAW로 촬영한 이미지는 HEIF 포맷으로 압축됨
- PNG: 대표적인 무손실 이미지 포맷
- JPEG: 대표적인 손실 압축 이미지 포맷
다운 스케일링 기준 만들기
애플리케이션에서 이미지가 가장 크게 표시되는 부분은 홈 화면에서 프로필 카드 뷰 부분이므로, 프로필 카드 뷰 기준으로 다운 스케일링 할 사이즈의 기준을 만들었다.
프로필 카드 뷰는 오토레이아웃으로 이미지 크기가 결정되므로, 각 디바이스마다 이미지 사이즈가 다를 수 있다. 따라서 각 극단값을 몇개 측정했다.
디바이스별 프로필 카드 사이즈 (포인트 기준)
- iPhone 16 Pro Max: 392 * 591
- iPhone SE 3: 327 * 372
- iPhone 12 mini: 327 * 453
다운 스케일링을 어떤 방식으로 해야 할지도 결정해야 한다. 모든 디바이스에 맞게 각각 다운스케일링을 할 수 없으므로, 최대한 효율적인 방식을 선택하려고 했다.
고정 수치로 다운스케일링 하기
가로 세로 비율을 기반으로 고정 수치로 다운스케일링 하는 방식이다, 원본 이미지의 캔버스 사이즈에 영향을 받지 않는다는 특징이 있어서 원본 이미지가 아무리 크더라도, 다운 스케일링 이후의 용량에 영향을 끼치지 않는다는 장점이 있다.
- 후보 캔버스 사이즈 (픽셀 단위)
- 392 * 591: 프로 맥스 사이즈
- 186 * 295: 위 사이즈의 절반
캔버스 크기 비율로 다운스케일링 하기
어짜피 기존 이미지의 캔버스 사이즈가 다 다르니까, 비율로 다운샘플링 하는 것은 크게 의미가 없다고 생각했다.
- ex) 8000 * 4000 캔버스나 4000 * 2000 캔버스나 둘 다 고해상도인데 똑같이 가로 세로를 반으로 줄인다 하더라도 4000 * 2000, 2000 * 1000으로 다름
- 결국 다운 샘플링의 일관성이 매우 떨어지게 됨.
결론
392 * 591의 고정 수치로 다운 스케일링 하려고 한다. 파일들의 크기를 최대한 일관적으로 유지할 수 있고, 업 스케일의 필요성이 없어진다.
ImageDownsampler 클래스
resizeImage(_:to:)
타겟 사이즈를 기준으로 이미지 캔버스 사이즈를 변경하는 함수
private func resizeImage(to targetSize: CGSize) -> CGContext? {
let colorSpace = CGColorSpaceCreateDeviceRGB()
guard let context = CGContext(
data: nil,
width: Int(targetSize.width),
height: Int(targetSize.height),
bitsPerComponent: 8,
bytesPerRow: 0,
space: colorSpace,
bitmapInfo: CGImageAlphaInfo.premultipliedFirst.rawValue
) else {
return nil
}
return context
}
downscaleProfileImage(_:)
이미지를 프로필 카드 뷰에 맞도록 다운스케일링 하는 함수
func downscaleProfileImage(_ data: Data) async throws -> Data {
guard let cgImage = CGImage.createFromData(data: data) else {
throw ImageSamplingError.invalidImageData
}
let downscaleRatio = cgImage.width > cgImage.height ?
ImageConstants.profileTargetSize.height / Double(cgImage.height) :
ImageConstants.profileTargetSize.width / Double(cgImage.width)
let newSize = CGSize(
width: Double(cgImage.width) * downscaleRatio,
height: Double(cgImage.height) * downscaleRatio
)
guard let downsampledImageContext = resizeImage(to: newSize) else {
throw ImageSamplingError.downsamplingFailed
}
downsampledImageContext.draw(
cgImage,
in: CGRect(origin: .zero, size: newSize)
)
guard let downsampledImageData = downsampledImageContext
.makeImage()?
.jpgData else {
throw ImageSamplingError.downsamplingFailed
}
return downsampledImageData
}
파라미터로 들어온 이미지의 사이즈의 가로, 세로 길이 중, 더 짧은 쪽을 기준으로 이미지를 다운스케일링 합니다.
원본이미지의 비율을 유지하면서, 이미지의 캔버스의 사이즈만 변화시킵니다.
프로젝트에 적용하기
의존성 주입
let saveProfileImageUseCase: SaveProfileImageUseCase = SaveProfileImageUseCaseImpl(
remoteImageManager: SupabaseStorageManager(
networkProvider: SNMNetworkProvider()
),
userDefaultsManager: UserDefaultsManager.shared,
imageSampler: ImageSampler()
)
라우터 부분에서 ImageSampler를 생성하여 의존성 주입을 했습니다.
실제 동작
let downsampledImageData = try await downsampledData
let thumbnailImageData = try await thumbnailData
async let uploadDownsampled: () = remoteImageManager.upload(
imageData: downsampledImageData,
fileName: fileName,
mimeType: .image
)
async let uploadThumbnail: () = remoteImageManager.upload(
imageData: thumbnailImageData,
fileName: thumbnailName,
mimeType: .image
)
try await uploadDownsampled
try await uploadThumbnail
썸네일 이미지를 만드는 작업과 프로필 이미지를 다운스케일링 하는 과정 모두 코스트가 큰 작업이라 생각하여서 비동기 실행이 가능하도록 처리했습니다.
썸네일 불러오기
func requestProfileImage(id: UUID, imageName: String) {
Task { @MainActor in
let imageData = try await requestProfileImageUseCase.execute(fileName: "thumbnail_\(imageName)")
presenter?.didFetchProfileImage(id: id, imageData: imageData)
}
}
메이트 리스트에서 썸네일 이미지를 불러오도록 수정했습니다.
동작 검증
기타 인사이트
CGError
는 Error
를 컨펌하지 않는다…