Streaming de IA Multimodal con Cámara en Tiempo Real: Arquitectura para iOS y Android

Implementación práctica de streaming de vídeo con análisis de IA conversacional en tiempo real para apps móviles nativas

iOS Android IA Multimodal Streaming CoreML Camera

Streaming de IA Multimodal con Cámara en Tiempo Real: Arquitectura para iOS y Android

Marzo de 2026 está siendo el mes de la IA multimodal en tiempo real. Google ha anunciado en su I/O que la IA será “una capa base que atraviese todo el ecosistema móvil”, y ya vemos apps que permiten apuntar la cámara a cualquier objeto y mantener una conversación fluida con IA sobre lo que está viendo.

Después de implementar esta funcionalidad en tres proyectos diferentes durante las últimas semanas, te cuento cómo construir una arquitectura robusta para streaming de IA multimodal con cámara que funcione en producción.

El Problema Real

La IA multimodal conversacional con cámara no es solo “subir una foto a GPT-4V”. Es streaming continuo de frames, procesamiento diferido, gestión de memoria bajo presión, y mantener una conversación coherente mientras el contexto visual cambia constantemente.

Las apps que he portado de Swift a Kotlin manejan flujos de 120K+ frames por sesión, con análisis de IA cada 2-3 segundos y respuestas conversacionales en menos de 800ms. El usuario espera que funcione como magia, pero por dentro es una orquestación compleja.

Arquitectura de Streaming: Tres Capas

1. Capa de Captura: Camera Pipeline

En iOS con AVFoundation:

// CameraStreamManager.swift
import AVFoundation
import Vision

class CameraStreamManager: NSObject {
    private let captureSession = AVCaptureSession()
    private let videoOutput = AVCaptureVideoDataOutput()
    private let processingQueue = DispatchQueue(label: "camera.processing", qos: .userInitiated)
    
    // Buffer circular para frames - memoria limitada
    private var frameBuffer: CircularBuffer<CVPixelBuffer> = CircularBuffer(capacity: 10)
    private var isProcessing = false
    
    func setupCamera() {
        captureSession.sessionPreset = .medium // 720p suficiente para IA
        
        guard let camera = AVCaptureDevice.default(.builtInWideAngleCamera, for: .video, position: .back),
              let input = try? AVCaptureDeviceInput(device: camera) else { return }
        
        captureSession.addInput(input)
        
        // Configuración crítica: formato de pixel
        videoOutput.videoSettings = [
            kCVPixelBufferPixelFormatTypeKey as String: kCVPixelFormatType_32BGRA
        ]
        
        videoOutput.setSampleBufferDelegate(self, queue: processingQueue)
        captureSession.addOutput(videoOutput)
    }
}

extension CameraStreamManager: AVCaptureVideoDataOutputSampleBufferDelegate {
    func captureOutput(_ output: AVCaptureOutput, 
                      didOutput sampleBuffer: CMSampleBuffer, 
                      from connection: AVCaptureConnection) {
        
        guard !isProcessing,
              let pixelBuffer = CMSampleBufferGetImageBuffer(sampleBuffer) else { return }
        
        // Almacenar en buffer circular - evita memory pressure
        frameBuffer.push(pixelBuffer)
        
        // Trigger análisis cada 2.5 segundos
        if shouldAnalyzeFrame() {
            isProcessing = true
            Task {
                await processCurrentFrame()
                isProcessing = false
            }
        }
    }
    
    private func shouldAnalyzeFrame() -> Bool {
        // Lógica adaptativa: más análisis si hay movimiento
        let timeSinceLastAnalysis = Date().timeIntervalSince(lastAnalysisTime)
        let motionThreshold = detectMotion() ? 1.5 : 3.0
        return timeSinceLastAnalysis > motionThreshold
    }
}

En Android con CameraX:

// CameraStreamManager.kt
import androidx.camera.core.*
import androidx.camera.lifecycle.ProcessCameraProvider
import kotlinx.coroutines.*
import java.util.concurrent.ConcurrentLinkedQueue

class CameraStreamManager(private val context: Context) {
    private val processingScope = CoroutineScope(Dispatchers.Default + SupervisorJob())
    private val frameBuffer = ConcurrentLinkedQueue<ImageProxy>()
    private val maxBufferSize = 8
    
    @OptIn(ExperimentalGetImage::class)
    private val imageAnalyzer = ImageAnalysis.Analyzer { imageProxy ->
        // Gestión de buffer - evitar memory leaks
        if (frameBuffer.size >= maxBufferSize) {
            frameBuffer.poll()?.close()
        }
        
        frameBuffer.offer(imageProxy)
        
        if (shouldAnalyzeFrame() && !isProcessing.get()) {
            isProcessing.set(true)
            processingScope.launch {
                processCurrentFrame()
                isProcessing.set(false)
            }
        } else {
            imageProxy.close()
        }
    }
    
    fun setupCamera(lifecycleOwner: LifecycleOwner, previewView: PreviewView) {
        val cameraProviderFuture = ProcessCameraProvider.getInstance(context)
        
        cameraProviderFuture.addListener({
            val cameraProvider = cameraProviderFuture.get()
            
            val preview = Preview.Builder()
                .setTargetResolution(Size(720, 1280))
                .build()
                .also { it.setSurfaceProvider(previewView.surfaceProvider) }
            
            val imageAnalysis = ImageAnalysis.Builder()
                .setTargetResolution(Size(720, 1280))
                .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
                .build()
                .also { it.setAnalyzer(ContextCompat.getMainExecutor(context), imageAnalyzer) }
            
            try {
                cameraProvider.unbindAll()
                cameraProvider.bindToLifecycle(
                    lifecycleOwner, 
                    CameraSelector.DEFAULT_BACK_CAMERA,
                    preview, 
                    imageAnalysis
                )
            } catch (exc: Exception) {
                Log.e("CameraStream", "Camera binding failed", exc)
            }
        }, ContextCompat.getMainExecutor(context))
    }
}

2. Capa de Procesamiento: IA Pipeline

Aquí está la magia. No enviamos todos los frames a la IA - sería carísimo y lento. Usamos una estrategia híbrida:

// AIStreamProcessor.swift
import CoreML
import Vision

class AIStreamProcessor {
    private let visionModel: VNCoreMLModel
    private let conversationContext = ConversationContext()
    private let frameDiffer = FrameDifferenceAnalyzer()
    
    init() {
        // Modelo ligero para detección local de cambios
        let config = MLModelConfiguration()
        config.computeUnits = .cpuAndNeuralEngine
        
        guard let model = try? MobileNetV3Small(configuration: config),
              let visionModel = try? VNCoreMLModel(for: model.model) else {
            fatalError("No se pudo cargar modelo local")
        }
        
        self.visionModel = visionModel
    }
    
    func processFrame(_ pixelBuffer: CVPixelBuffer) async -> AIStreamResult {
        // Paso 1: Análisis local de diferencias
        let frameDiff = await frameDiffer.calculateDifference(pixelBuffer)
        
        if frameDiff.significantChange {
            // Paso 2: Descripción local rápida
            let localDescription = await generateLocalDescription(pixelBuffer)
            
            // Paso 3: Solo si hay cambio significativo, enviamos a API
            if localDescription.needsRemoteAnalysis {
                return await processWithRemoteAI(pixelBuffer, context: localDescription)
            }
        }
        
        return .noChange
    }
    
    private func generateLocalDescription(_ pixelBuffer: CVPixelBuffer) async -> LocalDescription {
        return await withCheckedContinuation { continuation in
            let request = VNCoreMLRequest(model: visionModel) { request, error in
                guard let observations = request.results as? [VNClassificationObservation],
                      let topResult = observations.first else {
                    continuation.resume(returning: LocalDescription.empty)
                    return
                }
                
                let description = LocalDescription(
                    primaryObject: topResult.identifier,
                    confidence: topResult.confidence,
                    needsRemoteAnalysis: topResult.confidence > 0.85
                )
                
                continuation.resume(returning: description)
            }
            
            let handler = VNImageRequestHandler(cvPixelBuffer: pixelBuffer, options: [:])
            try? handler.perform([request])
        }
    }
    
    private func processWithRemoteAI(_ pixelBuffer: CVPixelBuffer, 
                                   context: LocalDescription) async -> AIStreamResult {
        // Comprimir y enviar a API remota
        guard let imageData = pixelBuffer.toJPEG(quality: 0.7) else {
            return .error("No se pudo comprimir imagen")
        }
        
        let prompt = buildContextualPrompt(context: context)
        
        do {
            let response = try await OpenAIAPI.shared.analyzeImage(
                imageData: imageData,
                prompt: prompt,
                conversationId: conversationContext.currentId
            )
            
            conversationContext.addExchange(prompt: prompt, response: response.content)
            
            return .success(
                response: response.content,
                confidence: response.confidence,
                timestamp: Date()
            )
        } catch {
            return .error("Error en API remota: \(error.localizedDescription)")
        }
    }
    
    private func buildContextualPrompt(context: LocalDescription) -> String {
        let recentContext = conversationContext.getRecentExchanges(limit: 3)
        let contextSummary = recentContext.isEmpty ? "" : "Contexto previo: \(recentContext.joined(separator: " | "))"
        
        return """
        Analiza esta imagen de cámara en tiempo real. Detecté localmente: \(context.primaryObject) (conf: \(context.confidence)).
        
        \(contextSummary)
        
        Responde de forma conversacional y natural, como si fueras un asistente que ve lo mismo que el usuario. 
        Si hay preguntas previas sin responder, relaciónalo con la imagen actual.
        Máximo 2 frases, tono amigable.
        """
    }
}

Para Android, la implementación es similar usando TensorFlow Lite:

// AIStreamProcessor.kt
import org.tensorflow.lite.Interpreter
import org.tensorflow.lite.support.image.TensorImage
import org.tensorflow.lite.support.image.ImageProcessor
import org.tensorflow.lite.support.image.ops.ResizeOp

class AIStreamProcessor(private val context: Context) {
    private lateinit var tfliteInterpreter: Interpreter
    private val imageProcessor = ImageProcessor.Builder()
        .add(ResizeOp(224, 224, ResizeOp.ResizeMethod.BILINEAR))
        .build()
    
    private val conversationContext = ConversationContext()
    private val frameDiffer = FrameDifferenceAnalyzer()
    
    init {
        setupTFLite()
    }
    
    private fun setupTFLite() {
        val options = Interpreter.Options()
        options.setNumThreads(4)
        options.setUseNNAPI(true) // Android Neural Networks API
        
        val modelBuffer = loadModelFile("mobilenet_v3_small.tflite")
        tfliteInterpreter = Interpreter(modelBuffer, options)
    }
    
    suspend fun processFrame(imageProxy: ImageProxy): AIStreamResult = withContext(Dispatchers.Default) {
        val frameDiff = frameDiffer.calculateDifference(imageProxy)
        
        if (frameDiff.significantChange) {
            val localDescription = generateLocalDescription(imageProxy)
            
            if (localDescription.needsRemoteAnalysis) {
                return@withContext processWithRemoteAI(imageProxy, localDescription)
            }
        }
        
        AIStreamResult.NoChange
    }
    
    private suspend fun generateLocalDescription(imageProxy: ImageProxy): LocalDescription {
        return withContext(Dispatchers.Default) {
            val tensorImage = TensorImage.fromBitmap(imageProxy.toBitmap())
            val processedImage = imageProcessor.process(tensorImage)
            
            val outputArray = Array(1) { FloatArray(1001) } // ImageNet classes
            
            tfliteInterpreter.run(processedImage.buffer, outputArray)
            
            val predictions = outputArray[0]
            val maxIndex = predictions.indices.maxByOrNull { predictions[it] } ?: 0
            val confidence = predictions[maxIndex]
            
            LocalDescription(
                primaryObject = getImageNetLabel(maxIndex),
                confidence = confidence,
                needsRemoteAnalysis = confidence > 0.85f
            )
        }
    }
}

3. Capa de Conversación: Context Management

La clave está en mantener contexto conversacional sin explotar la memoria:

// ConversationContext.swift
class ConversationContext {
    private let maxContextLength = 15
    private var exchanges: [ConversationExchange] = []
    private let contextSummarizer = ContextSummarizer()
    
    struct ConversationExchange {
        let timestamp: Date
        let visualContext: String
        let userPrompt: String?
        let aiResponse: String
        let relevanceScore: Double
    }
    
    func addExchange(prompt: String?, response: String, visualContext: String = "") {
        let exchange = ConversationExchange(
            timestamp: Date(),
            visualContext: visualContext,
            userPrompt: prompt,
            aiResponse: response,
            relevanceScore: calculateRelevanceScore(response)
        )
        
        exchanges.append(exchange)
        
        // Gestión de memoria: comprimir contexto viejo
        if exchanges.count > maxContextLength {
            let oldExchanges = Array(exchanges.prefix(5))
            let summary = contextSummarizer.summarize(oldExchanges)
            
            // Reemplazar primeros 5 intercambios con resumen
            exchanges = [createSummaryExchange(summary)] + Array(exchanges.dropFirst(5))
        }
    }
    
    func getRecentExchanges(limit: Int = 5) -> [String] {
        return exchanges
            .suffix(limit)
            .compactMap { exchange in
                let visual = exchange.visualContext.isEmpty ? "" : " [Viendo: \(exchange.visualContext)]"
                return "\(exchange.aiResponse)\(visual)"
            }
    }
    
    private func calculateRelevanceScore(_ response: String) -> Double {
        // Puntuación basada en palabras clave, longitud, referencias visuales
        let keywords = ["veo", "observo", "imagen", "objeto", "color", "forma", "ubicación"]
        let keywordCount = keywords.reduce(0) { count, keyword in
            count + response.lowercased().components(separatedBy: keyword).count - 1
        }
        
        let lengthScore = min(Double(response.count) / 100.0, 1.0)
        let keywordScore = min(Double(keywordCount) / 3.0, 1.0)
        
        return (lengthScore + keywordScore) / 2.0
    }
}

Optimizaciones de Producción

1. Gestión de Memoria Agresiva

En apps de streaming continuo, la memoria es el cuello de botella principal:

// MemoryManager.swift
class StreamMemoryManager {
    private let memoryWarningThreshold: UInt64 = 150 * 1024 * 1024 // 150MB
    private let criticalMemoryThreshold: UInt64 = 200 * 1024 * 1024 // 200MB
    
    func checkMemoryPressure() -> MemoryPressureLevel {
        let memoryUsage = getCurrentMemoryUsage()
        
        switch memoryUsage {
        case ..<memoryWarningThreshold:
            return .normal
        case memoryWarningThreshold..<criticalMemoryThreshold:
            return .warning
        default:
            return .critical
        }
    }
    
    func handleMemoryPressure(_ level: MemoryPressureLevel) {
        switch level {
        case .warning:
            // Reducir calidad de frames, limpiar buffer
            CameraStreamManager.shared.setFrameQuality(.medium)
            ConversationContext.shared.compressOldContext()
            
        case .critical:
            // Pausar procesamiento temporalmente
            CameraStreamManager.shared.pauseProcessing()
            AIStreamProcessor.shared.clearCache()
            
            // Forzar garbage collection
            autoreleasepool {
                // Liberar recursos no esenciales
            }
            
        case .normal:
            break
        }
    }
    
    private func getCurrentMemoryUsage() -> UInt64 {
        var taskInfo = mach_task_basic_info()
        var count = mach_msg_type_number_t(MemoryLayout<mach_task_basic_info>.size) / 4
        
        let kerr: kern_return_t = withUnsafeMutablePointer(to: &taskInfo) {
            $0.withMemoryRebound(to: integer_t.self, capacity: 1) {
                task_info(mach_task_self_, task_flavor_t(MACH_TASK_BASIC_INFO), $0, &count)
            }
        }
        
        return kerr == KERN_SUCCESS ? taskInfo.resident_size : 0
    }
}

2. Batching Inteligente de Requests

No todos los frames necesitan análisis inmediato. Usamos batching adaptativo:

// RequestBatcher.swift
class AIRequestBatcher {
    private var pendingRequests: [BatchRequest] = []
    private let batchTimer = Timer.scheduledTimer(withTimeInterval: 1.5, repeats: true) { _ in
        processBatch()
    }
    
    struct BatchRequest {
        let frame: CVPixelBuffer
        let priority: RequestPriority
        let timestamp: Date
        let completion: (AIStreamResult) -> Void
    }
    
    enum RequestPriority {
        case low       // Análisis rutinario
        case medium    // Cambio detectado
        case high      // Usuario preguntó algo específico
    }
    
    func addRequest(_ request: BatchRequest) {
        // Evitar duplicados recientes
        pendingRequests.removeAll { existingRequest in
            abs(existingRequest.timestamp.timeIntervalSince(request.timestamp)) < 0.5
        }
        
        pendingRequests.append(request)
        
        // Ordenar por prioridad
        pendingRequests.sort { $0.priority.rawValue > $1.priority.rawValue }
        
        // Límite de batch - procesar si es urgente
        if pendingRequests.count >= 3 || request.priority == .high {
            processBatch()
        }
    }
    
    private func processBatch() {
        guard !pendingRequests.isEmpty else { return }
        
        let currentBatch = Array(pendingRequests.prefix(3))
        pendingRequests = Array(pendingRequests.dropFirst(3))
        
        Task {
            await processBatchConcurrently(currentBatch)
        }
    }
    
    private func processBatchConcurrently(_ batch: [BatchRequest]) async {
        await withTaskGroup(of: Void.self) { group in
            for request in batch {
                group.addTask {
                    let result = await AIStreamProcessor.shared.processFrame(request.frame)
                    await MainActor.run {
                        request.completion(result)
                    }
                }
            }
        }
    }
}

3. Fallback y Resilencia

Los servicios de IA fallan. Siempre tener plan B:

// AIServiceResilience.swift
class ResilientAIService {
    private let primaryService = OpenAIService()
    private let fallbackService = AnthropicService()
    private let localFallback = CoreMLAnalyzer()
    
    private var primaryServiceHealthy = true
    private var fallbackServiceHealthy = true
    
    func analyzeImage(_ imageData: Data, prompt: String) async -> AIStreamResult {
        // Intentar servicio principal
        if primaryServiceHealthy {
            do {
                let result = try await primaryService.analyze(imageData, prompt: prompt)
                return .success(response: result.content, confidence: result.confidence, timestamp: Date())
            } catch {
                primaryServiceHealthy = false
                scheduleHealthCheck(for: primaryService)
                print("⚠️ Servicio principal falló: \(error)")
            }
        }
        
        // Fallback a servicio secundario
        if fallbackServiceHealthy {
            do {
                let result = try await fallbackService.analyze(imageData, prompt: prompt)
                return .success(response: result.content, confidence: result.confidence * 0.9, timestamp: Date())
            } catch {
                fallbackServiceHealthy = false
                scheduleHealthCheck(for: fallbackService)
                print("⚠️ Servicio fallback falló: \(error)")
            }
        }
        
        // Último recurso: análisis local
        print("🔄 Usando análisis local como último recurso")
        let localResult = await localFallback.analyze(imageData)
        return .success(
            response: "Veo \(localResult.objects.joined(separator: ", ")). (Análisis local - servicios remotos no disponibles)",
            confidence: localResult.confidence * 0.7,
            timestamp: Date()
        )
    }
    
    private func scheduleHealthCheck(for service: Any) {
        Task {
            try await Task.sleep(nanoseconds: 30_000_000_000) // 30 segundos
            await performHealthCheck(for: service)
        }
    }
}

Integración con UI: SwiftUI y Compose

SwiftUI Implementation

// CameraAIView.swift
import SwiftUI

struct CameraAIView: View {
    @StateObject private var cameraManager = CameraStreamManager()
    @StateObject private var aiProcessor = AIStreamProcessor()
    @State private var currentResponse = ""
    @State private var isProcessing = false
    @State private var conversationHistory: [AIResponse] = []
    
    var body: some View {
        ZStack {
            // Vista de cámara
            CameraPreview(cameraManager: cameraManager)
                .ignoresSafeArea()
            
            // Overlay de respuestas
            VStack {
                Spacer()
                
                if isProcessing {
                    ProcessingOverlay()
                        .transition(.opacity)
                }
                
                if !currentResponse.isEmpty {
                    AIResponseCard(response: currentResponse)
                        .transition(.move(edge: .bottom).combined(with: .opacity))
                        .animation(.spring(response: 0.6, dampingFraction: 0.8), value: currentResponse)
                }
            }
            .padding()
        }
        .onAppear {
            setupStreamingPipeline()
        }
    }
    
    private func setupStreamingPipeline() {
        cameraManager.frameProcessor = { frame in
            guard !isProcessing else { return }
            
            Task { @MainActor in
                isProcessing = true
                let result = await aiProcessor.processFrame(frame)
                
                switch result {
                case .success(let response, let confidence, let timestamp):
                    withAnimation {
                        currentResponse = response
                        conversationHistory.append(AIResponse(content: response, timestamp: timestamp, confidence: confidence))
                    }
                    
                    // Auto-hide después de 8 segundos
                    Task {
                        try await Task.sleep(nanoseconds: 8_000_000_000)
                        withAnimation {
                            if currentResponse == response {
                                currentResponse = ""
                            }
                        }
                    }
                    
                case .noChange:
                    break
                    
                case .error(let message):
                    print("❌ Error procesando frame: \(message)")
                }
                
                isProcessing = false
            }
        }
    }
}

struct AIResponseCard: View {
    let response: String
    
    var body: some View {
        Text(response)
            .font(.body)
            .foregroundColor(.white)
            .padding()
            .background(
                RoundedRectangle(cornerRadius: 12)
                    .fill(.ultraThinMaterial)
                    .overlay(
                        RoundedRectangle(cornerRadius: 12)
                            .stroke(Color.white.opacity(0.2), lineWidth: 1)
                    )
            )
    }
}

Jetpack Compose Implementation

// CameraAIScreen.kt
@Composable
fun CameraAIScreen(
    viewModel: CameraAIViewModel = hiltViewModel()
) {
    val context = LocalContext.current
    val lifecycleOwner = LocalLifecycleOwner.current
    
    val currentResponse by viewModel.currentResponse.collectAsState()
    val isProcessing by viewModel.isProcessing.collectAsState()
    
    Box(modifier = Modifier.fillMaxSize()) {
        // Vista de cámara
        AndroidView(
            factory = { ctx ->
                PreviewView(ctx).apply {
                    viewModel.setupCamera(lifecycleOwner, this)
                }
            },
            modifier = Modifier.fillMaxSize()
        )
        
        // Overlay de respuestas
        Column(
            modifier = Modifier
                .fillMaxSize()
                .padding(16.dp),
            verticalArrangement = Arrangement.Bottom
        ) {
            AnimatedVisibility(
                visible = isProcessing,
                enter = fadeIn(animationSpec = tween(300)),
                exit = fadeOut(animationSpec = tween(300))
            ) {
                ProcessingIndicator()
            }
            
            AnimatedVisibility(
                visible = currentResponse.isNotEmpty(),
                enter = slideInVertically(
                    initialOffsetY = { it },
                    animationSpec = spring(dampingRatio = 0.8f)
                ) + fadeIn(),
                exit = slideOutVertically(
                    targetOffsetY = { it },
                    animationSpec = tween(300)
                ) + fadeOut()
            ) {
                AIResponseCard(response = currentResponse)
            }
        }
    }
}

@Composable
private fun AIResponseCard(response: String) {
    Card(
        modifier = Modifier
            .fillMaxWidth()
            .padding(horizontal = 16.dp),
        colors = CardDefaults.cardColors(
            containerColor = MaterialTheme.colorScheme.surface.copy(alpha = 0.9f)
        ),
        elevation = CardDefaults.cardElevation(defaultElevation = 8.dp)
    ) {
        Text(
            text = response,
            modifier = Modifier.padding(16.dp),
            style = MaterialTheme.typography.bodyLarge,
            color = MaterialTheme.colorScheme.onSurface
        )
    }
}

Performance en Producción

Después de tres implementaciones en producción, estos son los números que importan:

Métricas Objetivo

  • Latencia total: <800ms (captura → respuesta)
  • Uso de memoria: <200MB pico, <150MB promedio
  • Batería: <15% adicional por hora de uso
  • Precisión: >85% en escenarios típicos

Optimizaciones que Funcionan

  1. Frame rate adaptativo: 30fps para preview, análisis cada 2-3s
  2. Compresión inteligente: JPEG quality 0.7, resize a 720p máximo
  3. Caché local: Responses similares se reutilizan 5-10 segundos
  4. Batching: Máximo 3 requests concurrentes

Lo que No Funciona

  • Analizar cada frame: consume batería exponencialmente
  • Resolución 4K: innecesaria para IA, mata performance
  • Context ilimitado: memory leak garantizado después de 10 min
  • Retry agresivo: mejor fallar rápido que consumir recursos

Conclusiones Prácticas

La IA multimodal en tiempo real ya no es experimental - es una feature que los usuarios esperan. Pero implementarla bien requiere una arquitectura híbrida que combine procesamiento local para filtrado y APIs remotas para análisis profundo.

Las tres lecciones más importantes:

  1. Local-first para filtrado: No todo frame necesita ir al servidor
  2. Contexto comprimido: La memoria importa más que la precisión perfecta
  3. Fallbacks robusten: Los servicios de IA fallan, diseña para ello

En las próximas semanas voy a profundizar en la optimización de modelos CoreML y TensorFlow Lite específicamente para este tipo de flujos. También tengo pendiente un análisis de costos de APIs - porque 120K frames por sesión pueden costar más de lo que piensas.

¿Has implementado algo similar? Me interesa saber qué optimizaciones te han funcionado mejor.