Estado: PLAN APROBADO, pendiente de implementación. Este documento describe el diseño acordado. No hay código implementado aún.
Cardinal-System es una arquitectura de nodos distribuidos donde todos comparten
un log de eventos replicado (estilo blockchain-lite vía eventBroadcast). Hay dos
tipos de nodos de cómputo:
- Nodos réplica (workers genéricos): aumentan capacidad de cómputo horizontal.
- Nodos especializados: realizan UNA tarea concreta en loop continuo y reportan resultados al enjambre.
Este plan añade dos nodos especializados de visión por computador:
vision-yolo— detección de objetos (YOLO) sobre el stream de una cámara.vision-face— reconocimiento facial (face_recognition) contra una base de datos de caras conocidas almacenada en un volumen compartido.
Ambos son procesos Python autónomos que hablan el protocolo Envelope
(un JSON por frame de texto WebSocket) directamente con el leader. El leader, además
de recibir eventos, actúa como orquestador: puede levantar/detener contenedores
de visión pasándoles la URL de la cámara.
┌─────────────────────────────────────────────────┐
│ leader (Swift) │
│ │
Envelope (ws/json) │ - recibe .detection / .faceRecognition │
┌───────────────────────┤ - informa (log / fase 2: CLI + LLM acciones) │
│ │ - ORQUESTA: lanza/para contenedores vision │
│ │ según CAMERA_URL (Docker socket) │
│ └───────┬──────────────────────┬──────────────────┘
│ │ docker socket │ docker socket
│ ▼ ▼
│ ┌──────────────────┐ ┌──────────────────────┐
└────────────────────┤ vision-yolo │ │ vision-face │
.detection │ (Python) │ │ (Python) │
│ ultralytics+ │ │ face_recognition │
│ opencv │ │ + opencv │
│ CAM: /dev/video0│ │ CAM: /dev/video1 │
└──────────────────┘ │ DB: known_faces/ │
│ (volumen compart.) │
└──────────────────────┘
.faceRecognition ──────▲
| # | Decisión | Elección |
|---|---|---|
| 1 | Orquestación | El leader instancia/para contenedores de visión pasándoles la CAMERA_URL (Docker socket). |
| 2 | Payloads | Separados: DetectionPayload y FacePayload (type-safe, más claro para el LLM). |
| 3 | DB de caras | Carpeta en un volumen (./FaceRecognition/known_faces), con .gitignore para no commitear fotos reales. |
| 4 | Pruebas | En la Mac (cámara integrada + USB), workers nativos o con DEMO_MODE. |
| 5 | Acciones del LLM | Fase 2 (después de validar que los eventos llegan). |
Decisiones de diseño adicionales:
- Contenedores separados e independientes: la inferencia corre en Python en su
propio contenedor por servicio. El core Swift NO carga modelos ML ni lanza
subprocesos. Los workers Python hablan
Envelopecomo un cliente WebSocket más. - Cada contenedor de visión captura su propia fuente (su propia cámara). No se comparten frames entre contenedores (la pipeline "YOLO → crop → face" queda como optimización futura fuera de este plan).
- Multi-cámara / RTSP:
CAMERA_URLes un parámetro. Para IP remota solo cambia la URL (rtsp://usuario:pass@ip:554/stream). Varios nodos = varios contenedores. - Reconexión: los workers Python llevan loop de reconexión con backoff
exponencial +
restart: unless-stoppeden compose. (El core Swift sigue sin reconexión nativa; es deuda aparte.)
Sources/Cardinal-System/Network/Envelope.swift — 2 casos nuevos:
case detection(DetectionPayload)
case faceRecognition(FacePayload)Nuevo Sources/Cardinal-System/Network/VisionPayloads.swift:
public struct DetectionPayload: Codable, Sendable, Equatable {
public var cameraID: String
public var timestamp: Date
public var frame: Int
public var objects: [DetectedObject]
}
public struct DetectedObject: Codable, Sendable, Equatable {
public var label: String
public var confidence: Float
public var bbox: [Float] // [x, y, w, h]
}
public struct FacePayload: Codable, Sendable, Equatable {
public var cameraID: String
public var timestamp: Date
public var frame: Int
public var faces: [RecognizedFace]
}
public struct RecognizedFace: Codable, Sendable, Equatable {
public var name: String // "alice" | "unknown"
public var confidence: Float // 1 - distancia
public var bbox: [Float]
}Compatibilidad: nodos viejos que reciban los casos nuevos simplemente no los
decodifican y los ignoran (comportamiento ya existente en handleIncomingText).
Cero breaking changes.
Sources/Cardinal-System/System/Node.swift — 2 casos nuevos en receive(_:from:):
case .detection(let p) where role == .leader:
onDetection?(p)
try? await network.broadcast(.detection(p)) // réplicas / CLI
case .faceRecognition(let p) where role == .leader:
onFaceRecognition?(p)
try? await network.broadcast(.faceRecognition(p))- hooks
public var onDetection/onFaceRecognition(mismo patrón queonDelegateReply).
Sources/Run/ALO.swift — el leader loguea ambos eventos (fase "informar").
Tests — round-trip Codable de ambos envelopes.
Nuevo Sources/Cardinal-System/System/ContainerOrchestrator.swift (actor):
- Se comunica con el Docker daemon para crear/arrancar/parar/eliminar contenedores de visión.
- Implementación: invoca la Docker HTTP API sobre el socket
(
unix:///var/run/docker.sock) usandoasync-http-client(ya es dependencia). Endpoints:POST /containers/create,/start,/stop,DELETE /containers/{id}. Sin shell-out adocker, sin dependencia nueva pesada. - API:
actor ContainerOrchestrator {
func launchVisionNode(kind: VisionKind, cameraURL: String, cameraID: String) async throws -> String // containerID
func stopVisionNode(containerID: String) async throws
func listVisionNodes() async throws -> [VisionNodeInfo]
}
enum VisionKind { case yolo, face }
- Los contenedores lanzados reciben env (
LEADER_HOST=leader,CAMERA_URL,CAMERA_ID, etc.) y etiquetas (cardinal.managed=true,cardinal.kind=yolo) para que el leader liste solo los suyos.
El leader necesita el socket de Docker montado:
leader:
volumes:
- /var/run/docker.sock:/var/run/docker.sockEsto le da al leader control total del Docker del host (equivale a root en la
máquina). Aceptable en una red/lab privada. Alternativa futura: docker-socket-proxy
limitando a endpoints de contenedores.
El leader lee env vars estáticas (VISION_YOLO_CAMERA_URL, VISION_FACE_CAMERA_URL)
y al arrancar lanza los contenedores correspondientes. La orquestación dinámica por
demanda (que el LLM decida levantar una cámara nueva) queda para la fase 2.
Vision/ (carpeta nueva, aislada):
yolo_worker.py: OpenCV capturaCAMERA_URL→ YOLOv8n cadaDETECTION_INTERVAL_MS→ envía.detectionpor WS con reconexión/backoff.DEMO_MODE=truegenera detecciones sintéticas (pruebas sin cámara).requirements.txt:ultralytics,opencv-python-headless,websockets.Dockerfile.vision:python:3.12-slim+libgl1/libglib2.0+ descargayolov8n.pten el build.
FaceRecognition/ (carpeta nueva, aislada):
face_worker.py: carga la DB deknown_faces/*.jpg(encoding → nombre), capturaCAMERA_URL, cadaDETECTION_INTERVAL_MSdetecta caras y compara (RECOGNITION_TOLERANCE) → envía.faceRecognitionpor WS con reconexión. Hot-reload de la DB al detectar cambios en el directorio (añadir personas sin reiniciar).DEMO_MODEpara pruebas.requirements.txt:face_recognition,opencv-python-headless,websockets.Dockerfile.face:python:3.12-slim+cmake/build-essential/libboostpara compilardlib(build lento, imagen ~1.5-2GB).known_faces/con un README de ejemplo (las fotos reales van al.gitignore).
docker-compose.yml — 2 servicios nuevos con profile vision + volumen de caras
- docker.sock en el leader:
vision-yolo:
build: { context: ./Vision, dockerfile: Dockerfile.vision }
profiles: ["vision"]
environment: { LEADER_HOST: leader, CAMERA_URL: /dev/video0, CAMERA_ID: usb-cam-0,
DETECTION_INTERVAL_MS: 500, CONFIDENCE_THRESHOLD: 0.5, DEMO_MODE: "false" }
devices: ["/dev/video0:/dev/video0"]
restart: unless-stopped
depends_on: [leader]
vision-face:
build: { context: ./FaceRecognition, dockerfile: Dockerfile.face }
profiles: ["vision"]
environment: { LEADER_HOST: leader, CAMERA_URL: /dev/video1, CAMERA_ID: usb-cam-1,
FACES_DB_PATH: /app/known_faces, RECOGNITION_TOLERANCE: 0.6,
DETECTION_INTERVAL_MS: 1000, DEMO_MODE: "false" }
devices: ["/dev/video1:/dev/video1"]
volumes: ["./FaceRecognition/known_faces:/app/known_faces:ro"]
restart: unless-stopped
depends_on: [leader]Uso:
docker compose --profile vision up -d # leader + worker + vision-yolo + vision-face
DEMO_MODE=true docker compose --profile vision up -d # prueba sin cámara.gitignore: ignorar FaceRecognition/known_faces/*.jpg|*.png (privacidad).
Pruebas en la Mac (decisión 4): la cámara integrada y la USB aparecen como
fuentes 0 y 1. Nota: en macOS con Docker Desktop el paso de /dev/video* al
contenedor no es directo (la VM de Docker no expone webcams fácilmente). Caminos:
- (a) Correr los workers Python nativos en la Mac (
python3 yolo_worker.pyconLEADER_HOST=<ip-del-host>), apuntando al leader en Docker en Linux. - (b) Mover todo a la Mac y correr los workers nativos con
cv2.VideoCapture(0)(integrada) ycv2.VideoCapture(1)(USB).
CAMERA_URL abstrae la fuente (0/1 en Mac, /dev/videoN en Linux, rtsp://
en el futuro).
| Archivo / Carpeta | Cambio | Riesgo |
|---|---|---|
Sources/Cardinal-System/Network/Envelope.swift |
+2 casos | Mínimo (aditivo) |
Sources/Cardinal-System/Network/VisionPayloads.swift |
nuevo | Nulo |
Sources/Cardinal-System/System/Node.swift |
+2 casos + 2 hooks | Bajo |
Sources/Run/ALO.swift |
loguea eventos | Bajo |
Sources/Cardinal-System/System/ContainerOrchestrator.swift |
nuevo (Docker HTTP API) | Medio |
Vision/ |
nuevo servicio Python | Nulo (aislado) |
FaceRecognition/ |
nuevo servicio Python + DB | Nulo (aislado) |
docker-compose.yml |
+2 servicios + sock en leader | Bajo |
.gitignore |
+ignorar fotos DB | Nulo |
Tests/ |
round-trip Codable | Aditivo |
Envelope+ payloads + tests Codable.Node.receivecasos + hooks + log enALO.swift.Vision/yolo_worker.py(DEMO primero) +Dockerfile.vision.FaceRecognition/face_worker.py+ DB +Dockerfile.face.- Compose + gitignore.
ContainerOrchestrator+ arranque por env en leader.- Prueba end-to-end con
DEMO_MODE=true(sin cámara): python → leader → log. - Prueba real en la Mac (workers nativos, 2 cámaras).
- LLM del leader decide acciones basadas en detecciones/caras (contexto en prompt
- filtro de labels, ej.
ALERT_LABELS=person,car).
- filtro de labels, ej.
- Cerrar el puente
.callTool(ejecutar acciones reales; hoy no ejecuta nada). - Orquestación dinámica (el LLM levanta/para cámaras por demanda).
- CLI mostrando eventos en vivo.