๐ Visual Intelligence
์นด๋ฉ๋ผ ๋ฒํผ์ผ๋ก ์ธ์์ ์ดํดํ๋ AI ์๊ฐ ๋ถ์
โจ Visual Intelligence๋?
Visual Intelligence๋ iOS 18์์ ๋์ ๋ ํ์ ์ ์ธ ์๊ฐ AI ํ๋ ์์ํฌ์ ๋๋ค. iPhone์ Camera Control ๋ฒํผ์ ๊ธธ๊ฒ ๋๋ฌ ์ค์๊ฐ์ผ๋ก ์ฃผ๋ณ ํ๊ฒฝ์ ๋ถ์ํ๊ณ , ๊ฐ์ฒด ์ธ์, ํ ์คํธ ์ถ์ถ, ์ฅ์ ์ ๋ณด, ์ ํ ๊ฒ์ ๋ฑ ๋ค์ํ ์๊ฐ ์ ๋ณด๋ฅผ ์ ๊ณตํฉ๋๋ค. Apple Intelligence์ ํตํฉ๋์ด ์จ๋๋ฐ์ด์ค์์ ๋น ๋ฅด๊ณ ์ ํํ๊ฒ ์ฒ๋ฆฌ๋๋ฉฐ, ์ฌ์ฉ์ ํ๋ผ์ด๋ฒ์๋ฅผ ์๋ฒฝํ๊ฒ ๋ณดํธํฉ๋๋ค.
๐ธ 1. ๊ธฐ๋ณธ ์ค์
Visual Intelligence ์๋น์ค๋ฅผ ์ด๊ธฐํํ๊ณ ๊ถํ์ ์์ฒญํฉ๋๋ค.
import SwiftUI import AVFoundation // Visual Intelligence ์๋น์ค ๊ด๋ฆฌ์ @Observable class VisualIntelligenceManager { var isAuthorized = false var isAnalyzing = false var currentResult: AnalysisResult? // ์นด๋ฉ๋ผ ๊ถํ ์์ฒญ func requestAuthorization() async -> Bool { let status = await AVCaptureDevice.requestAccess(for: .video) await MainActor.run { isAuthorized = status } return status } // Visual Intelligence ์ง์ ํ์ธ func isSupported() -> Bool { // iOS 18+ ๋ฐ Camera Control ์ง์ ๊ธฐ๊ธฐ if #available(iOS 18.0, *) { return true } return false } } // ๋ถ์ ๊ฒฐ๊ณผ ํ์ struct AnalysisResult: Identifiable { let id = UUID() let type: AnalysisType let data: Any let timestamp = Date() } enum AnalysisType { case objectDetection case textExtraction case placeRecognition case productSearch case barcodeScanning }
๐๏ธ 2. ์ค์๊ฐ ๊ฐ์ฒด ์ธ์
์นด๋ฉ๋ผ๋ฅผ ํตํด ์ค์๊ฐ์ผ๋ก ๋ฌผ์ฒด๋ฅผ ๊ฐ์งํ๊ณ ๋ถ๋ฅํฉ๋๋ค.
import SwiftUI import Vision struct ObjectDetectionView: View { @State private var manager = VisualIntelligenceManager() @State private var detectedObjects: [DetectedObject] = [] @State private var showCamera = false var body: some View { VStack(spacing: 20) { // ์นด๋ฉ๋ผ ํ๋ฆฌ๋ทฐ ZStack { Rectangle() .fill(Color.black) .aspectRatio(3/4, contentMode: .fit) .cornerRadius(12) if manager.isAnalyzing { ProgressView("๋ถ์ ์ค...") .tint(.white) } // ๊ฐ์ง๋ ๊ฐ์ฒด ์ค๋ฒ๋ ์ด ForEach(detectedObjects) { obj in Rectangle() .stroke(Color.green, lineWidth: 2) .frame(width: obj.bounds.width, height: obj.bounds.height) .position(x: obj.bounds.midX, y: obj.bounds.midY) .overlay(alignment: .topLeading) { Text(obj.label) .font(.caption) .padding(4) .background(Color.green) .foregroundStyle(.white) .cornerRadius(4) .offset(x: obj.bounds.minX, y: obj.bounds.minY) } } } // ๊ฐ์ง๋ ๊ฐ์ฒด ๋ฆฌ์คํธ VStack(alignment: .leading, spacing: 12) { Text("๊ฐ์ง๋ ๊ฐ์ฒด") .font(.headline) if detectedObjects.isEmpty { Text("์นด๋ฉ๋ผ ๋ฒํผ์ ๊ธธ๊ฒ ๋๋ฌ ์ค์บ์ ์์ํ์ธ์") .font(.subheadline) .foregroundStyle(.secondary) } else { ForEach(detectedObjects) { obj in HStack { Image(systemName: obj.icon) .foregroundStyle(.blue) VStack(alignment: .leading) { Text(obj.label) .font(.subheadline) Text("\(Int(obj.confidence * 100))% ์ ๋ขฐ๋") .font(.caption) .foregroundStyle(.secondary) } Spacer() Button("๊ฒ์") { searchObject(obj) } .buttonStyle(.bordered) } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(8) } } } .padding() } .task { await manager.requestAuthorization() } } func searchObject(_ object: DetectedObject) { print("๐ ๊ฒ์: \(object.label)") } } // ๊ฐ์ง๋ ๊ฐ์ฒด ๋ชจ๋ธ struct DetectedObject: Identifiable { let id = UUID() let label: String let confidence: Double let bounds: CGRect var icon: String { // ๊ฐ์ฒด ํ์ ์ ๋ฐ๋ผ ์์ด์ฝ ๋ฐํ switch label.lowercased() { case let l where l.contains("person"): return "person.fill" case let l where l.contains("car"): return "car.fill" case let l where l.contains("dog"): return "pawprint.fill" case let l where l.contains("cat"): return "cat.fill" default: return "cube.fill" } } }
๐ 3. ํ ์คํธ ์ถ์ถ (OCR)
์ด๋ฏธ์ง ์ ํ ์คํธ๋ฅผ ์ธ์ํ๊ณ ์ถ์ถํ์ฌ ๋ณต์ฌ, ๋ฒ์ญ, ๊ฒ์์ด ๊ฐ๋ฅํฉ๋๋ค.
import SwiftUI import Vision @Observable class TextExtractionManager { var extractedText: [RecognizedText] = [] var isProcessing = false // ํ ์คํธ ์ถ์ถ (Live Text) func extractText(from image: UIImage) async { await MainActor.run { isProcessing = true } guard let cgImage = image.cgImage else { return } let request = VNRecognizeTextRequest { request, error in guard error == nil, let observations = request.results as? [VNRecognizedTextObservation] else { return } let recognizedTexts = observations.compactMap { observation in guard let topCandidate = observation.topCandidates(1).first else { return nil } return RecognizedText( text: topCandidate.string, confidence: topCandidate.confidence, bounds: observation.boundingBox ) } Task { @MainActor in self.extractedText = recognizedTexts self.isProcessing = false } } // ์ ํ๋ ์ค์ request.recognitionLevel = .accurate request.usesLanguageCorrection = true let handler = VNImageRequestHandler(cgImage: cgImage, options: [:]) try? handler.perform([request]) } } struct RecognizedText: Identifiable { let id = UUID() let text: String let confidence: Float let bounds: CGRect } struct TextExtractionView: View { @State private var manager = TextExtractionManager() @State private var selectedImage: UIImage? @State private var showImagePicker = false var body: some View { ScrollView { VStack(spacing: 20) { // ์ด๋ฏธ์ง ์ ํ if let selectedImage { Image(uiImage: selectedImage) .resizable() .scaledToFit() .cornerRadius(12) } else { Button { showImagePicker = true } label: { VStack { Image(systemName: "photo.badge.plus") .font(.system(size: 50)) Text("์ด๋ฏธ์ง ์ ํ") } .frame(height: 200) .frame(maxWidth: .infinity) .background(Color.gray.opacity(0.1)) .cornerRadius(12) } } // ์ถ์ถ๋ ํ ์คํธ if manager.isProcessing { ProgressView("ํ ์คํธ ์ถ์ถ ์ค...") .padding() } else if !manager.extractedText.isEmpty { VStack(alignment: .leading, spacing: 12) { Text("์ถ์ถ๋ ํ ์คํธ") .font(.headline) ForEach(manager.extractedText) { item in HStack { Text(item.text) .textSelection(.enabled) Spacer() Menu { Button { copyText(item.text) } label: { Label("๋ณต์ฌ", systemImage: "doc.on.doc") } Button { translateText(item.text) } label: { Label("๋ฒ์ญ", systemImage: "translate") } Button { searchText(item.text) } label: { Label("๊ฒ์", systemImage: "magnifyingglass") } } label: { Image(systemName: "ellipsis.circle") } } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(8) } } } } .padding() } .onChange(of: selectedImage) { _, newImage in if let newImage { Task { await manager.extractText(from: newImage) } } } } func copyText(_ text: String) { UIPasteboard.general.string = text } func translateText(_ text: String) { print("๐ ๋ฒ์ญ: \(text)") } func searchText(_ text: String) { print("๐ ๊ฒ์: \(text)") } }
๐ 4. ์ฅ์ ์ธ์
๋๋๋งํฌ, ๊ฑด๋ฌผ, ์ฅ์๋ฅผ ์ธ์ํ๊ณ ๊ด๋ จ ์ ๋ณด๋ฅผ ์ ๊ณตํฉ๋๋ค.
import SwiftUI import CoreLocation import MapKit struct RecognizedPlace: Identifiable { let id = UUID() let name: String let category: String let address: String let coordinate: CLLocationCoordinate2D let rating: Double? let photoURL: URL? } @Observable class PlaceRecognitionManager { var recognizedPlace: RecognizedPlace? var isRecognizing = false // ์ฅ์ ์ธ์ func recognizePlace(from image: UIImage, location: CLLocation?) async { isRecognizing = true // ์ค์ ๋ก๋ Vision + Maps API ์ฌ์ฉ try? await Task.sleep(for: .seconds(1)) // ์์ ๋ฐ์ดํฐ recognizedPlace = RecognizedPlace( name: "์ ํ ํํฌ", category: "๋๋๋งํฌ", address: "One Apple Park Way, Cupertino, CA", coordinate: CLLocationCoordinate2D(latitude: 37.3349, longitude: -122.0090), rating: 4.8, photoURL: nil ) isRecognizing = false } } struct PlaceRecognitionView: View { @State private var manager = PlaceRecognitionManager() @State private var cameraPosition: MapCameraPosition = .automatic var body: some View { VStack(spacing: 20) { // ์นด๋ฉ๋ผ ๋ทฐ (์ค์ ๋ก๋ AVCaptureSession) Rectangle() .fill(Color.black) .aspectRatio(3/4, contentMode: .fit) .cornerRadius(12) .overlay { if manager.isRecognizing { ProgressView("์ฅ์ ์ธ์ ์ค...") .tint(.white) } } // ์ธ์๋ ์ฅ์ ์ ๋ณด if let place = manager.recognizedPlace { VStack(alignment: .leading, spacing: 16) { // ์ฅ์ ์ ๋ณด VStack(alignment: .leading, spacing: 8) { HStack { Text(place.name) .font(.title2) .fontWeight(.bold) Spacer() if let rating = place.rating { HStack(spacing: 4) { Image(systemName: "star.fill") .foregroundStyle(.yellow) Text("\(rating, specifier: "%.1f")") .fontWeight(.semibold) } } } Text(place.category) .font(.subheadline) .foregroundStyle(.secondary) Label(place.address, systemImage: "mappin.circle") .font(.footnote) } .padding() .background(Color.gray.opacity(0.1)) .cornerRadius(12) // ์ง๋ Map(position: $cameraPosition) { Marker(place.name, coordinate: place.coordinate) } .frame(height: 200) .cornerRadius(12) // ์ก์ ๋ฒํผ HStack(spacing: 12) { Button { openInMaps(place) } label: { Label("์ง๋์์ ๋ณด๊ธฐ", systemImage: "map") .frame(maxWidth: .infinity) } .buttonStyle(.borderedProminent) Button { sharePlace(place) } label: { Label("๊ณต์ ", systemImage: "square.and.arrow.up") .frame(maxWidth: .infinity) } .buttonStyle(.bordered) } } .padding(.horizontal) } Spacer() } .padding(.vertical) } func openInMaps(_ place: RecognizedPlace) { let mapItem = MKMapItem( placemark: MKPlacemark(coordinate: place.coordinate) ) mapItem.name = place.name mapItem.openInMaps() } func sharePlace(_ place: RecognizedPlace) { print("๐ค \(place.name) ๊ณต์ ") } }
๐๏ธ 5. ์ ํ ๊ฒ์
์ํ์ ์ธ์ํ๊ณ ์จ๋ผ์ธ์์ ์ ์ฌํ ์ ํ์ ์ฐพ์ต๋๋ค.
import SwiftUI struct Product: Identifiable { let id = UUID() let name: String let price: Decimal let brand: String let imageURL: URL? let productURL: URL let similarity: Double // ์ ์ฌ๋ 0-1 } @Observable class ProductSearchManager { var searchResults: [Product] = [] var isSearching = false // ์๊ฐ์ ์ ์ฌ ์ ํ ๊ฒ์ func searchSimilarProducts(from image: UIImage) async { isSearching = true // ์ค์ ๋ก๋ Vision + ์ผํ API ์ฐ๋ try? await Task.sleep(for: .seconds(1.5)) // ์์ ๊ฒฐ๊ณผ searchResults = [ Product( name: "๋์ดํค ์์ด ๋งฅ์ค", price: 189000, brand: "Nike", imageURL: nil, productURL: URL(string: "https://nike.com")!, similarity: 0.95 ), Product( name: "์๋๋ค์ค ์ธํธ๋ผ๋ถ์คํธ", price: 219000, brand: "Adidas", imageURL: nil, productURL: URL(string: "https://adidas.com")!, similarity: 0.87 ) ] isSearching = false } } struct ProductSearchView: View { @State private var manager = ProductSearchManager() @State private var selectedImage: UIImage? var body: some View { ScrollView { VStack(spacing: 20) { // ์ค์บํ ์ ํ ์ด๋ฏธ์ง if let selectedImage { Image(uiImage: selectedImage) .resizable() .scaledToFit() .frame(height: 200) .cornerRadius(12) } // ๊ฒ์ ์ค if manager.isSearching { VStack(spacing: 12) { ProgressView() Text("์ ์ฌ ์ ํ ๊ฒ์ ์ค...") .font(.subheadline) .foregroundStyle(.secondary) } .padding() } // ๊ฒ์ ๊ฒฐ๊ณผ if !manager.searchResults.isEmpty { VStack(alignment: .leading, spacing: 16) { Text("์ ์ฌ ์ ํ \(manager.searchResults.count)๊ฐ") .font(.headline) ForEach(manager.searchResults) { product in ProductRow(product: product) } } } } .padding() } } } struct ProductRow: View { let product: Product var body: some View { HStack(spacing: 12) { // ์ ํ ์ด๋ฏธ์ง RoundedRectangle(cornerRadius: 8) .fill(Color.gray.opacity(0.2)) .frame(width: 80, height: 80) .overlay { Image(systemName: "shoeprint.fill") .font(.title) .foregroundStyle(.secondary) } // ์ ํ ์ ๋ณด VStack(alignment: .leading, spacing: 4) { Text(product.brand) .font(.caption) .foregroundStyle(.secondary) Text(product.name) .font(.subheadline) .fontWeight(.medium) Text("\(product.price as NSDecimalNumber)์") .font(.subheadline) .fontWeight(.bold) // ์ ์ฌ๋ HStack(spacing: 4) { Image(systemName: "checkmark.circle.fill") .font(.caption) .foregroundStyle(.green) Text("\(Int(product.similarity * 100))% ์ผ์น") .font(.caption) .foregroundStyle(.secondary) } } Spacer() // ๋ฐ๋ก๊ฐ๊ธฐ Button { UIApplication.shared.open(product.productURL) } label: { Image(systemName: "arrow.right.circle.fill") .font(.title2) .foregroundStyle(.blue) } } .padding() .background(Color.gray.opacity(0.05)) .cornerRadius(12) } }
๐ฏ 6. Camera Control ํตํฉ
iPhone 16์ Camera Control ๋ฒํผ๊ณผ ํตํฉํ์ฌ ๋น ๋ฅธ ์คํ์ ์ง์ํฉ๋๋ค.
import SwiftUI // Camera Control ์ ์ค์ฒ ํธ๋ค๋ง @Observable class CameraControlManager { var isPressed = false var pressStartTime: Date? // ๊ธธ๊ฒ ๋๋ฅด๊ธฐ ๊ฐ์ง func handleLongPress() { isPressed = true pressStartTime = Date() // Visual Intelligence ํ์ฑํ activateVisualIntelligence() } // ๋ฒํผ ๋๊ธฐ func handleRelease() { guard let startTime = pressStartTime else { return } let duration = Date().timeIntervalSince(startTime) if duration > 0.5 { // ๊ธด ๋๋ฅด๊ธฐ: ํ๋ฉด ์บก์ฒ ๋ฐ ๋ถ์ captureAndAnalyze() } isPressed = false pressStartTime = nil } func activateVisualIntelligence() { print("๐ธ Visual Intelligence ํ์ฑํ") } func captureAndAnalyze() { print("๐ ํ๋ฉด ์บก์ฒ ๋ฐ ๋ถ์ ์์") } } struct CameraControlDemoView: View { @State private var manager = CameraControlManager() var body: some View { VStack { Text("Camera Control ๋ฒํผ์ ๊ธธ๊ฒ ๋๋ฅด์ธ์") .font(.headline) .padding() // ์๋ฎฌ๋ ์ด์ ์ฉ ๋ฒํผ Button { manager.handleLongPress() Task { try? await Task.sleep(for: .seconds(1)) manager.handleRelease() } } label: { Circle() .fill(manager.isPressed ? Color.blue : Color.gray) .frame(width: 80, height: 80) .overlay { Image(systemName: "camera.fill") .font(.title) .foregroundStyle(.white) } } } } }
๐ก HIG ๊ฐ์ด๋๋ผ์ธ
- ํ๋ผ์ด๋ฒ์ ์ฐ์ : ๋ชจ๋ ์ฒ๋ฆฌ๋ ์จ๋๋ฐ์ด์ค์์ ์ํ๋๋ฉฐ ์ฌ์ฉ์ ๋ฐ์ดํฐ๋ ์๋ฒ๋ก ์ ์ก๋์ง ์์
- ์ฆ๊ฐ์ ์ธ ํผ๋๋ฐฑ: ๋ถ์ ์์ ์ ์๊ฐ์ ํผ๋๋ฐฑ ์ ๊ณต
- ๋งฅ๋ฝ ์ธ์: ํ์ฌ ์ํฉ์ ๋ง๋ ์ ๋ณด๋ง ํ์
- ๋ช ํํ ์ก์ : ์ธ์๋ ์ ๋ณด๋ก ๋ฌด์์ ํ ์ ์๋์ง ๋ช ํํ ์ ์
- Camera Control ํตํฉ: ๊ธธ๊ฒ ๋๋ฅด๊ธฐ๋ก ๋น ๋ฅด๊ฒ ์ ๊ทผ ๊ฐ๋ฅ
๐ฏ ์ค์ ํ์ฉ
- ์ผํ ์ฑ: ์ ํ ์ค์บ ํ ๊ฐ๊ฒฉ ๋น๊ต ๋ฐ ๊ตฌ๋งค
- ๋ฒ์ญ ์ฑ: ํ์งํ, ๋ฉ๋ดํ ์ค์๊ฐ ๋ฒ์ญ
- ๊ต์ก ์ฑ: ์์ ๋ฌธ์ ์ค์บ ๋ฐ ํด์ค ์ ๊ณต
- ์ฌํ ์ฑ: ๋๋๋งํฌ ์ธ์ ํ ๊ด๊ด ์ ๋ณด ํ์
- ์๋ฌผ ์ธ์: ์๋ฌผ ์ข ๋ฅ ๋ฐ ๊ด๋ฆฌ ๋ฐฉ๋ฒ ์๋ด