ajustado capture e hardware sync
This commit is contained in:
parent
f48741f92d
commit
f1e37c1dae
|
|
@ -11,9 +11,6 @@ from .radiometric_controller import RadiometricController
|
||||||
|
|
||||||
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
|
PHYSICAL_CHANNEL_NAMES = ("R", "G", "B", "RE", "NIR")
|
||||||
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
|
PHYSICAL_CHANNEL_COUNT = len(PHYSICAL_CHANNEL_NAMES)
|
||||||
OAK_FCC3_CLIENT_VERSION = "production_v1_2026_08_24"
|
|
||||||
PRODUCT_SCHEMA = "multispec_module_params_v3"
|
|
||||||
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
|
|
||||||
|
|
||||||
|
|
||||||
class OakFcc3Client:
|
class OakFcc3Client:
|
||||||
|
|
@ -44,110 +41,83 @@ class OakFcc3Client:
|
||||||
capture_mode="AUTO",
|
capture_mode="AUTO",
|
||||||
raw_policy="allow_single",
|
raw_policy="allow_single",
|
||||||
module_calibration_json=None,
|
module_calibration_json=None,
|
||||||
sync_mode="best",
|
|
||||||
sync_tolerance_ms=25.0,
|
|
||||||
mx_id=None,
|
mx_id=None,
|
||||||
imu_modo="rotation_vector",
|
imu_modo="rotation_vector",
|
||||||
imu_freq_hz=200,
|
imu_freq_hz=200,
|
||||||
evaluate_quality=True,
|
evaluate_quality=True,
|
||||||
require_product_contract=False,
|
|
||||||
|
hardware_sync_enabled=None,
|
||||||
|
frame_sync_master=None,
|
||||||
|
sync_mode=None,
|
||||||
|
sync_tolerance_ms=None,
|
||||||
|
buffer_size=None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
# width/height/bayer recebidos do caller ficam apenas como fallback legado.
|
self.width = width
|
||||||
# Em module_params de produção, hardware real + MP são a autoridade.
|
self.height = height
|
||||||
self.legacy_width = int(width)
|
self.bayer = bayer
|
||||||
self.legacy_height = int(height)
|
self.fps = fps
|
||||||
self.legacy_bayer = str(bayer or "BGGR").upper()
|
self.frame_type = frame_type
|
||||||
|
self.output_dtype = output_dtype
|
||||||
self.fps = float(fps)
|
self.capture_mode = capture_mode
|
||||||
self.frame_type = str(frame_type).upper()
|
self.raw_policy = raw_policy
|
||||||
self.output_dtype = str(output_dtype).lower()
|
|
||||||
self.capture_mode = str(capture_mode).upper()
|
|
||||||
self.raw_policy = str(raw_policy).lower()
|
|
||||||
self.module_calibration_json = module_calibration_json
|
self.module_calibration_json = module_calibration_json
|
||||||
self.require_product_contract = bool(require_product_contract)
|
self.module_params = self._load_module_params(module_calibration_json)
|
||||||
|
|
||||||
self.module_params = self._load_module_params(
|
|
||||||
module_calibration_json,
|
|
||||||
required=self.require_product_contract,
|
|
||||||
)
|
|
||||||
self.product_contract = self._is_product_module_params(self.module_params)
|
|
||||||
|
|
||||||
if self.require_product_contract and not self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Contrato de produção obrigatório, mas module_params não foi "
|
|
||||||
"gerado pelo assembler oficial."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.fusion_config = self.module_params.get("fusion_config", {}) or {}
|
self.fusion_config = self.module_params.get("fusion_config", {}) or {}
|
||||||
self.camera_hardware = self._resolve_camera_hardware()
|
|
||||||
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
|
|
||||||
|
|
||||||
rgb_size = self.sensor_size_by_role.get(
|
|
||||||
"rgb",
|
|
||||||
[self.legacy_width, self.legacy_height],
|
|
||||||
)
|
|
||||||
self.rgb_native_width = int(rgb_size[0])
|
|
||||||
self.rgb_native_height = int(rgb_size[1])
|
|
||||||
|
|
||||||
# Mantidos por compatibilidade, mas agora significam RGB de referência.
|
|
||||||
self.width = self.rgb_native_width
|
|
||||||
self.height = self.rgb_native_height
|
|
||||||
|
|
||||||
self.bayer = self._resolve_bayer_pattern()
|
|
||||||
self.default_target_size = self._resolve_default_target_size()
|
|
||||||
|
|
||||||
self.imu_modo = str(imu_modo).strip().lower()
|
self.imu_modo = str(imu_modo).strip().lower()
|
||||||
self.imu_freq_hz = int(imu_freq_hz)
|
self.imu_freq_hz = int(imu_freq_hz)
|
||||||
|
|
||||||
|
# Auditoria radiométrica completa do Raw5.
|
||||||
|
#
|
||||||
|
# True mantém o comportamento histórico e é útil para captura científica,
|
||||||
|
# normalize/auditoria e ferramentas offline.
|
||||||
|
#
|
||||||
|
# No runtime em tempo real deve ficar False: evaluate_frame_quality()
|
||||||
|
# calcula estatísticas/percentis pesados e não faz parte da montagem
|
||||||
|
# necessária para a inferência.
|
||||||
self.evaluate_quality = bool(evaluate_quality)
|
self.evaluate_quality = bool(evaluate_quality)
|
||||||
|
|
||||||
self.mx_id = str(mx_id) if mx_id else None
|
self.mx_id = str(mx_id) if mx_id else None
|
||||||
|
|
||||||
# Validação antecipada do modo produto. O Manager também repete estes
|
|
||||||
# checks antes de abrir o hardware, de propósito.
|
|
||||||
self._validate_static_product_contract()
|
|
||||||
|
|
||||||
self.svc = OakFcc3Service(
|
self.svc = OakFcc3Service(
|
||||||
timeout=10,
|
timeout=10,
|
||||||
fps=self.fps,
|
fps=fps,
|
||||||
width=self.width,
|
width=width,
|
||||||
height=self.height,
|
height=height,
|
||||||
frame_type=self.frame_type,
|
frame_type=frame_type,
|
||||||
output_dtype=self.output_dtype,
|
output_dtype=output_dtype,
|
||||||
capture_mode=self.capture_mode,
|
capture_mode=capture_mode,
|
||||||
raw_policy=self.raw_policy,
|
raw_policy=raw_policy,
|
||||||
sync_mode=sync_mode,
|
|
||||||
sync_tolerance_ms=sync_tolerance_ms,
|
|
||||||
mx_id=self.mx_id,
|
mx_id=self.mx_id,
|
||||||
module_calibration_json=module_calibration_json,
|
module_calibration_json=module_calibration_json,
|
||||||
module_params=self.module_params,
|
|
||||||
require_product_contract=self.require_product_contract,
|
|
||||||
imu_modo=self.imu_modo,
|
imu_modo=self.imu_modo,
|
||||||
imu_freq_hz=self.imu_freq_hz,
|
imu_freq_hz=self.imu_freq_hz,
|
||||||
|
|
||||||
|
hardware_sync_enabled=hardware_sync_enabled,
|
||||||
|
frame_sync_master=frame_sync_master,
|
||||||
|
sync_mode=sync_mode,
|
||||||
|
sync_tolerance_ms=sync_tolerance_ms,
|
||||||
|
buffer_size=buffer_size,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.applied_camera_controls = {}
|
self.applied_camera_controls = {}
|
||||||
self.radiometric_controller = None
|
self.radiometric_controller = None
|
||||||
self.radiometric_controller_enabled = False
|
|
||||||
|
|
||||||
self.core = RawProcessorCore(
|
self.core = RawProcessorCore(
|
||||||
sensor_width=self.rgb_native_width,
|
sensor_width=width,
|
||||||
sensor_height=self.rgb_native_height,
|
sensor_height=height,
|
||||||
bayer_pattern=self.bayer,
|
bayer_pattern=bayer,
|
||||||
calibration_json_path=module_calibration_json,
|
calibration_json_path=module_calibration_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
# Preview é apenas visual, porém também precisa usar o Bayer/raster
|
|
||||||
# reais do RGB para não mentir sobre a AR0234.
|
|
||||||
self.preview = RawProcessorPreview(
|
self.preview = RawProcessorPreview(
|
||||||
sensor_width=self.rgb_native_width,
|
sensor_width=width,
|
||||||
sensor_height=self.rgb_native_height,
|
sensor_height=height,
|
||||||
bayer_pattern=self.bayer,
|
bayer_pattern=bayer,
|
||||||
)
|
)
|
||||||
self._preview_cache = {
|
|
||||||
(self.rgb_native_width, self.rgb_native_height, self.bayer): self.preview,
|
|
||||||
}
|
|
||||||
|
|
||||||
def __enter__(self):
|
def __enter__(self):
|
||||||
self.start()
|
self.start()
|
||||||
|
|
@ -156,247 +126,19 @@ class OakFcc3Client:
|
||||||
def __exit__(self, exc_type, exc, tb):
|
def __exit__(self, exc_type, exc, tb):
|
||||||
self.stop()
|
self.stop()
|
||||||
|
|
||||||
def _load_module_params(self, path, required=False):
|
def _load_module_params(self, path):
|
||||||
if not path:
|
if not path or not os.path.isfile(path):
|
||||||
if required:
|
|
||||||
raise FileNotFoundError(
|
|
||||||
"module_params obrigatório no contrato de produção."
|
|
||||||
)
|
|
||||||
return {}
|
|
||||||
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
if required:
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"module_params não encontrado: {path}"
|
|
||||||
)
|
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"module_params root deve ser dict/object: {path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _is_product_module_params(module_params):
|
|
||||||
mp = module_params or {}
|
|
||||||
assembly = mp.get("assembly_metadata", {}) or {}
|
|
||||||
return bool(
|
|
||||||
mp.get("schema") == PRODUCT_SCHEMA
|
|
||||||
and assembly.get("schema") == ASSEMBLY_SCHEMA
|
|
||||||
and isinstance(mp.get("camera_hardware"), dict)
|
|
||||||
and isinstance(mp.get("sensor_size_by_role"), dict)
|
|
||||||
and isinstance(mp.get("calibration_provenance"), dict)
|
|
||||||
)
|
|
||||||
|
|
||||||
@staticmethod
|
|
||||||
def _normalize_size(value, label):
|
|
||||||
if not (isinstance(value, (list, tuple)) and len(value) == 2):
|
|
||||||
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
|
|
||||||
|
|
||||||
w = int(value[0])
|
|
||||||
h = int(value[1])
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
raise RuntimeError(f"{label} inválido: {value!r}")
|
|
||||||
return [w, h]
|
|
||||||
|
|
||||||
def _resolve_camera_hardware(self):
|
|
||||||
raw = self.module_params.get("camera_hardware", {}) or {}
|
|
||||||
|
|
||||||
if not self.product_contract:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
out = {}
|
|
||||||
expected = {
|
|
||||||
"rgb": ("CAM_A", ("OV9782", "AR0234")),
|
|
||||||
"re": ("CAM_B", ("OV9282",)),
|
|
||||||
"nir": ("CAM_C", ("OV9282",)),
|
|
||||||
}
|
|
||||||
|
|
||||||
for role, (expected_socket, allowed_sensors) in expected.items():
|
|
||||||
item = raw.get(role)
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
raise RuntimeError(f"camera_hardware sem role={role}")
|
|
||||||
|
|
||||||
socket = str(item.get("socket") or item.get("socket_name") or "").upper()
|
|
||||||
sensor = str(item.get("sensor") or item.get("sensor_name") or "").upper()
|
|
||||||
size = self._normalize_size(item.get("size"), f"camera_hardware.{role}.size")
|
|
||||||
|
|
||||||
if socket != expected_socket:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_hardware.{role}.socket={socket!r}, esperado={expected_socket!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if sensor not in allowed_sensors:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_hardware.{role}.sensor={sensor!r}, permitidos={allowed_sensors}"
|
|
||||||
)
|
|
||||||
|
|
||||||
out[role] = {
|
|
||||||
"role": role,
|
|
||||||
"socket": socket,
|
|
||||||
"sensor": sensor,
|
|
||||||
"size": size,
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _resolve_sensor_size_by_role(self):
|
|
||||||
if not self.product_contract:
|
|
||||||
return {
|
|
||||||
"rgb": [self.legacy_width, self.legacy_height],
|
|
||||||
"re": [self.legacy_width, self.legacy_height],
|
|
||||||
"nir": [self.legacy_width, self.legacy_height],
|
|
||||||
}
|
|
||||||
|
|
||||||
raw = self.module_params.get("sensor_size_by_role", {}) or {}
|
|
||||||
out = {}
|
|
||||||
|
|
||||||
for role in ("rgb", "re", "nir"):
|
|
||||||
size = self._normalize_size(
|
|
||||||
raw.get(role),
|
|
||||||
f"sensor_size_by_role.{role}",
|
|
||||||
)
|
|
||||||
expected = self.camera_hardware[role]["size"]
|
|
||||||
if size != expected:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"sensor_size_by_role.{role}={size} != camera_hardware={expected}"
|
|
||||||
)
|
|
||||||
out[role] = size
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _resolve_bayer_pattern(self):
|
|
||||||
value = self.module_params.get("bayer_pattern") if self.product_contract else None
|
|
||||||
bayer = str(value or self.legacy_bayer).upper()
|
|
||||||
|
|
||||||
if bayer not in ("RGGB", "BGGR", "GRBG", "GBRG"):
|
|
||||||
raise RuntimeError(f"bayer_pattern inválido: {bayer!r}")
|
|
||||||
|
|
||||||
return bayer
|
|
||||||
|
|
||||||
def _resolve_default_target_size(self):
|
|
||||||
value = (self.fusion_config or {}).get("target_size")
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
return self._normalize_size(value, "fusion_config.target_size")
|
|
||||||
|
|
||||||
def _resolve_target_size(self, target_size):
|
|
||||||
value = target_size if target_size is not None else self.default_target_size
|
|
||||||
if value is None:
|
|
||||||
return None
|
|
||||||
size = self._normalize_size(value, "target_size")
|
|
||||||
|
|
||||||
if self.product_contract and self.default_target_size is not None:
|
|
||||||
if size != self.default_target_size:
|
|
||||||
raise RuntimeError(
|
|
||||||
"target_size solicitado diverge do module_params homologado: "
|
|
||||||
f"requested={size}, calibrated_runtime={self.default_target_size}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return size
|
|
||||||
|
|
||||||
def _validate_static_product_contract(self):
|
|
||||||
if not self.product_contract:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.frame_type != "RAW_BRUTO":
|
|
||||||
raise RuntimeError(
|
|
||||||
"OakFcc3Client de produção aceita somente frame_type='RAW_BRUTO'."
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.raw_policy != "require_triple":
|
|
||||||
raise RuntimeError(
|
|
||||||
"OakFcc3Client de produção exige raw_policy='require_triple'."
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.capture_mode not in ("TRIPLE", "AUTO"):
|
|
||||||
raise RuntimeError(
|
|
||||||
"OakFcc3Client de produção exige capture_mode TRIPLE/AUTO."
|
|
||||||
)
|
|
||||||
|
|
||||||
expected_mx = (
|
|
||||||
(self.module_params.get("calibration_provenance", {}) or {})
|
|
||||||
.get("device_mx_id")
|
|
||||||
)
|
|
||||||
if expected_mx and self.mx_id and str(expected_mx) != self.mx_id:
|
|
||||||
raise RuntimeError(
|
|
||||||
"MX ID solicitado diverge da calibração homologada: "
|
|
||||||
f"requested={self.mx_id}, calibrated={expected_mx}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _sync_contract_from_manager_status(self, status):
|
|
||||||
if not isinstance(status, dict):
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.product_contract and not bool(status.get("product_contract", False)):
|
|
||||||
raise RuntimeError(
|
|
||||||
"Client carregou module_params produto, mas Manager não reconheceu o contrato."
|
|
||||||
)
|
|
||||||
|
|
||||||
sizes = status.get("sensor_size_by_role")
|
|
||||||
if isinstance(sizes, dict):
|
|
||||||
normalized = {
|
|
||||||
role: self._normalize_size(sizes.get(role), f"manager.sensor_size_by_role.{role}")
|
|
||||||
for role in ("rgb", "re", "nir")
|
|
||||||
}
|
|
||||||
if self.product_contract and normalized != self.sensor_size_by_role:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Manager e Client discordam sobre sensor_size_by_role: "
|
|
||||||
f"manager={normalized}, client={self.sensor_size_by_role}"
|
|
||||||
)
|
|
||||||
self.sensor_size_by_role = normalized
|
|
||||||
|
|
||||||
bayer = status.get("bayer_pattern")
|
|
||||||
if bayer:
|
|
||||||
bayer = str(bayer).upper()
|
|
||||||
if self.product_contract and bayer != self.bayer:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Manager Bayer={bayer} != Client/module_params={self.bayer}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def get_contract(self):
|
|
||||||
return {
|
|
||||||
"client_version": OAK_FCC3_CLIENT_VERSION,
|
|
||||||
"product_contract": bool(self.product_contract),
|
|
||||||
"require_product_contract": bool(self.require_product_contract),
|
|
||||||
"sensor_size_by_role": {
|
|
||||||
role: list(size)
|
|
||||||
for role, size in self.sensor_size_by_role.items()
|
|
||||||
},
|
|
||||||
"camera_hardware": json.loads(json.dumps(self.camera_hardware)),
|
|
||||||
"bayer_pattern": self.bayer,
|
|
||||||
"default_target_size": (
|
|
||||||
None if self.default_target_size is None
|
|
||||||
else list(self.default_target_size)
|
|
||||||
),
|
|
||||||
"evaluate_quality": bool(self.evaluate_quality),
|
|
||||||
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
|
|
||||||
}
|
|
||||||
|
|
||||||
def apply_module_camera_settings(self):
|
def apply_module_camera_settings(self):
|
||||||
camera_settings = self.module_params.get("camera_settings", {}) or {}
|
camera_settings = self.module_params.get("camera_settings", {}) or {}
|
||||||
|
|
||||||
if self.product_contract:
|
|
||||||
missing = [
|
|
||||||
role
|
|
||||||
for role in ("rgb", "re", "nir")
|
|
||||||
if not isinstance(camera_settings.get(role), dict)
|
|
||||||
]
|
|
||||||
if missing:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"module_params produto sem camera_settings para: {missing}"
|
|
||||||
)
|
|
||||||
|
|
||||||
applied = {}
|
applied = {}
|
||||||
roles = ("rgb", "re", "nir") if self.product_contract else tuple(camera_settings.keys())
|
|
||||||
|
|
||||||
for role in roles:
|
for role, settings in camera_settings.items():
|
||||||
settings = camera_settings.get(role)
|
|
||||||
if not isinstance(settings, dict):
|
if not isinstance(settings, dict):
|
||||||
continue
|
continue
|
||||||
|
|
||||||
|
|
@ -415,43 +157,17 @@ class OakFcc3Client:
|
||||||
}
|
}
|
||||||
|
|
||||||
self.applied_camera_controls = applied
|
self.applied_camera_controls = applied
|
||||||
|
|
||||||
if self.product_contract:
|
|
||||||
failed = {
|
|
||||||
role: value
|
|
||||||
for role, value in applied.items()
|
|
||||||
if not bool((value or {}).get("ok", False))
|
|
||||||
}
|
|
||||||
if failed:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Falha reaplicando camera_settings homologado: {failed}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return applied
|
return applied
|
||||||
|
|
||||||
def enable_radiometric_controller(self):
|
def enable_radiometric_controller(self):
|
||||||
cfg = self.module_params.get("radiometric_config", {}) or {}
|
|
||||||
enabled = bool(cfg.get("enabled", False))
|
|
||||||
|
|
||||||
# No produto atual este controller de exposição em campo é OFF.
|
|
||||||
# Não instanciamos um segundo piloto para ficar parado dentro do loop.
|
|
||||||
if not enabled:
|
|
||||||
self.radiometric_controller = None
|
|
||||||
self.radiometric_controller_enabled = False
|
|
||||||
return None
|
|
||||||
|
|
||||||
self.radiometric_controller = RadiometricController(
|
self.radiometric_controller = RadiometricController(
|
||||||
client=self,
|
client=self,
|
||||||
config_json_path=self.module_calibration_json,
|
config_json_path=self.module_calibration_json,
|
||||||
)
|
)
|
||||||
|
|
||||||
self.radiometric_controller.sync_from_camera_controls(
|
self.radiometric_controller.sync_from_camera_controls(self.applied_camera_controls)
|
||||||
self.applied_camera_controls
|
|
||||||
)
|
|
||||||
self.radiometric_controller.sync_from_actual_camera_controls()
|
self.radiometric_controller.sync_from_actual_camera_controls()
|
||||||
self.radiometric_controller_enabled = bool(
|
|
||||||
getattr(self.radiometric_controller, "enabled", True)
|
|
||||||
)
|
|
||||||
return self.radiometric_controller
|
return self.radiometric_controller
|
||||||
|
|
||||||
def update_radiometry(self, decoded, meta=None):
|
def update_radiometry(self, decoded, meta=None):
|
||||||
|
|
@ -491,45 +207,17 @@ class OakFcc3Client:
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
status = self.svc.get_status()
|
self.mx_id = self.svc.manager.mx_id
|
||||||
self.mx_id = str(status.get("mx_id") or self.mx_id or "") or None
|
|
||||||
self._sync_contract_from_manager_status(status)
|
|
||||||
except Exception:
|
except Exception:
|
||||||
# Se a validação de contrato falhar, fecha hardware antes de propagar.
|
pass
|
||||||
try:
|
|
||||||
self.svc.disconnect()
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
raise
|
|
||||||
|
|
||||||
# O Manager de produção já aplicou estes controles via initialControl.
|
|
||||||
# Reaplicamos após start como confirmação operacional e para manter
|
|
||||||
# compatibilidade com Managers legados durante a migração.
|
|
||||||
applied = self.apply_module_camera_settings()
|
applied = self.apply_module_camera_settings()
|
||||||
|
|
||||||
self.enable_radiometric_controller()
|
|
||||||
|
|
||||||
if print_debug:
|
if print_debug:
|
||||||
print("[OAK CLIENT] START:", resp)
|
print("[OAK CLIENT] START:", resp)
|
||||||
print("[OAK CLIENT] CONTRACT:", self.get_contract())
|
|
||||||
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
|
print("[OAK CLIENT] APPLIED CAMERA SETTINGS:", applied)
|
||||||
|
|
||||||
if isinstance(resp, dict):
|
self.enable_radiometric_controller()
|
||||||
resp = dict(resp)
|
|
||||||
resp["client_version"] = OAK_FCC3_CLIENT_VERSION
|
|
||||||
resp["product_contract"] = bool(self.product_contract)
|
|
||||||
resp["sensor_size_by_role"] = {
|
|
||||||
role: list(size)
|
|
||||||
for role, size in self.sensor_size_by_role.items()
|
|
||||||
}
|
|
||||||
resp["bayer_pattern"] = self.bayer
|
|
||||||
resp["default_target_size"] = (
|
|
||||||
None if self.default_target_size is None
|
|
||||||
else list(self.default_target_size)
|
|
||||||
)
|
|
||||||
resp["radiometric_controller_enabled"] = bool(
|
|
||||||
self.radiometric_controller_enabled
|
|
||||||
)
|
|
||||||
|
|
||||||
return resp
|
return resp
|
||||||
|
|
||||||
|
|
@ -541,29 +229,7 @@ class OakFcc3Client:
|
||||||
return self.svc.get_device_metrics()
|
return self.svc.get_device_metrics()
|
||||||
|
|
||||||
def get_status(self):
|
def get_status(self):
|
||||||
status = self.svc.get_status()
|
return self.svc.get_status()
|
||||||
if not isinstance(status, dict):
|
|
||||||
status = {}
|
|
||||||
else:
|
|
||||||
status = dict(status)
|
|
||||||
|
|
||||||
status.update({
|
|
||||||
"client_version": OAK_FCC3_CLIENT_VERSION,
|
|
||||||
"client_product_contract": bool(self.product_contract),
|
|
||||||
"client_require_product_contract": bool(self.require_product_contract),
|
|
||||||
"client_sensor_size_by_role": {
|
|
||||||
role: list(size)
|
|
||||||
for role, size in self.sensor_size_by_role.items()
|
|
||||||
},
|
|
||||||
"client_bayer_pattern": self.bayer,
|
|
||||||
"client_default_target_size": (
|
|
||||||
None if self.default_target_size is None
|
|
||||||
else list(self.default_target_size)
|
|
||||||
),
|
|
||||||
"evaluate_quality": bool(self.evaluate_quality),
|
|
||||||
"radiometric_controller_enabled": bool(self.radiometric_controller_enabled),
|
|
||||||
})
|
|
||||||
return status
|
|
||||||
|
|
||||||
def get_next_raw_frame(self, timeout=1.0):
|
def get_next_raw_frame(self, timeout=1.0):
|
||||||
return self.svc.capture_frame(timeout=timeout)
|
return self.svc.capture_frame(timeout=timeout)
|
||||||
|
|
@ -578,12 +244,6 @@ class OakFcc3Client:
|
||||||
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
|
frame_type = str(raw_meta.get("frame_type", self.frame_type)).upper()
|
||||||
meta = dict(raw_meta)
|
meta = dict(raw_meta)
|
||||||
|
|
||||||
if self.product_contract and frame_type != "RAW_BRUTO":
|
|
||||||
raise RuntimeError(
|
|
||||||
"OakFcc3Client produto recebeu frame_type não canônico: "
|
|
||||||
f"{frame_type!r}. Esperado='RAW_BRUTO'."
|
|
||||||
)
|
|
||||||
|
|
||||||
if frame_type == "RAW_BRUTO":
|
if frame_type == "RAW_BRUTO":
|
||||||
decoded = self.decode_stream_cameras(raw_frame, raw_meta)
|
decoded = self.decode_stream_cameras(raw_frame, raw_meta)
|
||||||
|
|
||||||
|
|
@ -680,25 +340,6 @@ class OakFcc3Client:
|
||||||
|
|
||||||
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
|
def build_infer_tensor(self, frame, meta, channels_expected, target_size=None):
|
||||||
channels_expected = self._validate_physical_channel_count(channels_expected)
|
channels_expected = self._validate_physical_channel_count(channels_expected)
|
||||||
target_size = self._resolve_target_size(target_size)
|
|
||||||
|
|
||||||
frame_type = str(
|
|
||||||
(meta or {}).get("frame_type", self.frame_type)
|
|
||||||
if isinstance(meta, dict)
|
|
||||||
else self.frame_type
|
|
||||||
).upper()
|
|
||||||
|
|
||||||
# No runtime quente evitamos o quality audit pesado. Decodificamos e
|
|
||||||
# usamos exatamente o mesmo caminho do CameraMultispectral.
|
|
||||||
if not self.evaluate_quality and frame_type == "RAW_BRUTO":
|
|
||||||
decoded = self.core.decode_stream_cameras(frame, meta)
|
|
||||||
return self.build_infer_tensor_from_decoded(
|
|
||||||
decoded=decoded,
|
|
||||||
meta=meta,
|
|
||||||
channels_expected=channels_expected,
|
|
||||||
target_size=target_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
return self.core.build_infer_tensor_from_stream(
|
return self.core.build_infer_tensor_from_stream(
|
||||||
frame,
|
frame,
|
||||||
meta,
|
meta,
|
||||||
|
|
@ -714,49 +355,41 @@ class OakFcc3Client:
|
||||||
target_size=None,
|
target_size=None,
|
||||||
evaluate_quality=None,
|
evaluate_quality=None,
|
||||||
):
|
):
|
||||||
|
"""
|
||||||
|
Monta o Raw5 físico a partir das câmeras já decodificadas.
|
||||||
|
|
||||||
|
evaluate_quality:
|
||||||
|
- None -> usa self.evaluate_quality
|
||||||
|
- True -> executa evaluate_frame_quality() e atualiza
|
||||||
|
core.last_frame_quality_result
|
||||||
|
- False -> não executa a auditoria pesada e limpa
|
||||||
|
core.last_frame_quality_result
|
||||||
|
|
||||||
|
A flag altera somente a auditoria de qualidade. Não altera decode,
|
||||||
|
radiometria, flat-field, homografia, crop/resize ou patch normalization.
|
||||||
|
"""
|
||||||
channels_expected = self._validate_physical_channel_count(channels_expected)
|
channels_expected = self._validate_physical_channel_count(channels_expected)
|
||||||
target_size = self._resolve_target_size(target_size)
|
|
||||||
|
|
||||||
if evaluate_quality is None:
|
if evaluate_quality is None:
|
||||||
evaluate_quality = self.evaluate_quality
|
evaluate_quality = self.evaluate_quality
|
||||||
evaluate_quality = bool(evaluate_quality)
|
evaluate_quality = bool(evaluate_quality)
|
||||||
|
|
||||||
# Core novo faz a geometria source->target em uma única etapa.
|
tensor = self.core.fuse_multispec_cameras(decoded, meta, channels_expected)
|
||||||
# NÃO redimensionar novamente depois da fusão.
|
tensor = self.core.resize_tensor_chw(tensor, target_size=target_size)
|
||||||
tensor = self.core.fuse_multispec_cameras(
|
|
||||||
decoded,
|
|
||||||
meta,
|
|
||||||
channels_expected,
|
|
||||||
target_size=target_size,
|
|
||||||
)
|
|
||||||
|
|
||||||
patch_cfg = getattr(
|
|
||||||
self.core,
|
|
||||||
"patch_normalization_config",
|
|
||||||
{},
|
|
||||||
) or {}
|
|
||||||
|
|
||||||
|
# Mantém paridade com build_infer_tensor_from_stream: se a calibração
|
||||||
|
# habilitar patch normalization, ela também vale no caminho decoded.
|
||||||
|
patch_cfg = getattr(self.core, "patch_normalization_config", {}) or {}
|
||||||
if bool(patch_cfg.get("enabled", False)):
|
if bool(patch_cfg.get("enabled", False)):
|
||||||
if self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"patch_normalization não é permitido no contrato produto."
|
|
||||||
)
|
|
||||||
tensor = self.core.apply_patch_normalization_to_tensor(tensor)
|
tensor = self.core.apply_patch_normalization_to_tensor(tensor)
|
||||||
|
|
||||||
if evaluate_quality:
|
if evaluate_quality:
|
||||||
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
|
self.core.last_frame_quality_result = self.core.evaluate_frame_quality(tensor)
|
||||||
else:
|
else:
|
||||||
# Evita deixar resultado antigo no objeto e evita percentis no hot path.
|
# Evita deixar um resultado antigo parecer referente ao frame atual.
|
||||||
self.core.last_frame_quality_result = {
|
self.core.last_frame_quality_result = None
|
||||||
"status": "skipped",
|
|
||||||
"usable_for_training": None,
|
|
||||||
"reason": "disabled_by_oak_fcc3_client",
|
|
||||||
"client_version": OAK_FCC3_CLIENT_VERSION,
|
|
||||||
}
|
|
||||||
|
|
||||||
return np.ascontiguousarray(
|
return tensor
|
||||||
tensor.astype(np.float32, copy=False)
|
|
||||||
)
|
|
||||||
|
|
||||||
def decode_stream_cameras(self, frame, meta):
|
def decode_stream_cameras(self, frame, meta):
|
||||||
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
|
if str(meta.get("frame_type", self.frame_type)).upper() == "PREVIEW":
|
||||||
|
|
@ -872,14 +505,10 @@ class OakFcc3Client:
|
||||||
arr = arr[:, :, 0]
|
arr = arr[:, :, 0]
|
||||||
|
|
||||||
if bit_depth == 10 and arr.ndim == 2:
|
if bit_depth == 10 and arr.ndim == 2:
|
||||||
role_size = self.sensor_size_by_role.get(
|
|
||||||
str(role).lower(),
|
|
||||||
[self.rgb_native_width, self.rgb_native_height],
|
|
||||||
)
|
|
||||||
raw16 = self.core.unpack_raw10_packed(
|
raw16 = self.core.unpack_raw10_packed(
|
||||||
arr,
|
arr,
|
||||||
sensor_width=int(info.get("width", role_size[0])),
|
sensor_width=int(info.get("width", self.width)),
|
||||||
sensor_height=int(info.get("height", role_size[1])),
|
sensor_height=int(info.get("height", self.height)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if role == "rgb":
|
if role == "rgb":
|
||||||
|
|
@ -941,7 +570,7 @@ class OakFcc3Client:
|
||||||
or cam_meta.get("bayer")
|
or cam_meta.get("bayer")
|
||||||
or stream_meta.get("bayer_pattern")
|
or stream_meta.get("bayer_pattern")
|
||||||
or bayer_pattern
|
or bayer_pattern
|
||||||
or self.bayer
|
or "RGGB"
|
||||||
)
|
)
|
||||||
bayer = str(bayer).upper()
|
bayer = str(bayer).upper()
|
||||||
|
|
||||||
|
|
@ -973,17 +602,19 @@ class OakFcc3Client:
|
||||||
real_w = int(cam_meta.get("width", sensor_width))
|
real_w = int(cam_meta.get("width", sensor_width))
|
||||||
real_h = int(cam_meta.get("height", sensor_height))
|
real_h = int(cam_meta.get("height", sensor_height))
|
||||||
|
|
||||||
cache_key = (real_w, real_h, bayer)
|
core = RawProcessorCore(
|
||||||
preview = self._preview_cache.get(cache_key)
|
sensor_width=real_w,
|
||||||
if preview is None:
|
sensor_height=real_h,
|
||||||
preview = RawProcessorPreview(
|
bayer_pattern=bayer,
|
||||||
sensor_width=real_w,
|
)
|
||||||
sensor_height=real_h,
|
|
||||||
bayer_pattern=bayer,
|
|
||||||
)
|
|
||||||
self._preview_cache[cache_key] = preview
|
|
||||||
|
|
||||||
raw16 = self.core.unpack_raw10_packed(
|
preview = RawProcessorPreview(
|
||||||
|
sensor_width=real_w,
|
||||||
|
sensor_height=real_h,
|
||||||
|
bayer_pattern=bayer,
|
||||||
|
)
|
||||||
|
|
||||||
|
raw16 = core.unpack_raw10_packed(
|
||||||
packed,
|
packed,
|
||||||
sensor_width=real_w,
|
sensor_width=real_w,
|
||||||
sensor_height=real_h,
|
sensor_height=real_h,
|
||||||
|
|
@ -1050,11 +681,6 @@ class OakFcc3Client:
|
||||||
|
|
||||||
|
|
||||||
def decode_oak_aligned_multispec(self, frame, meta):
|
def decode_oak_aligned_multispec(self, frame, meta):
|
||||||
if self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"MULTISPEC alinhado pela OAK é legado e não faz parte do contrato produto."
|
|
||||||
)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Decodifica frames já alinhados pela OAK.
|
Decodifica frames já alinhados pela OAK.
|
||||||
|
|
||||||
|
|
@ -1103,11 +729,6 @@ class OakFcc3Client:
|
||||||
return decoded
|
return decoded
|
||||||
|
|
||||||
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
|
def build_multispec_tensor_from_oak_aligned(self, decoded, meta=None):
|
||||||
if self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Tensor MULTISPEC pré-alinhado pela OAK é legado no contrato produto."
|
|
||||||
)
|
|
||||||
|
|
||||||
"""
|
"""
|
||||||
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
|
Monta CHW [R,G,B,RE,NIR] sem reaplicar homografia.
|
||||||
"""
|
"""
|
||||||
|
|
|
||||||
|
|
@ -12,60 +12,27 @@ import numpy as np
|
||||||
from itertools import product
|
from itertools import product
|
||||||
|
|
||||||
|
|
||||||
OAK_FCC3_MANAGER_VERSION = "production_v1_2026_08_24"
|
|
||||||
|
|
||||||
|
|
||||||
class OakFcc3Manager:
|
class OakFcc3Manager:
|
||||||
"""
|
"""
|
||||||
Hardware manager de produção para OAK-FFC-3 multiespectral.
|
Manager OAK-FFC-3 com dois fluxos principais:
|
||||||
|
|
||||||
Contrato oficial:
|
1) RAW_BRUTO
|
||||||
CAM_A = RGB = OV9782 1280x800 OU AR0234 1920x1200
|
- Mantém o comportamento antigo.
|
||||||
CAM_B = RE = OV9282 1280x800
|
- CAM_A/CAM_B/CAM_C enviam RAW10 packed direto para o PC.
|
||||||
CAM_C = NIR = OV9282 1280x800
|
- O PC faz decode, flat/radiometric, homografia/fusão/crop/resize.
|
||||||
|
|
||||||
Caminho oficial de produto:
|
2) MULTISPEC
|
||||||
RAW_BRUTO -> RAW10 packed nativo por câmera -> RawProcessorCore.
|
- Câmeras sempre em 800p nativo.
|
||||||
|
- OAK aplica homografia/crop/resize via ImageManip.
|
||||||
O Manager não calibra imagem. Ele abre/valida hardware, aplica a política
|
- PC recebe frames já alinhados:
|
||||||
inicial da câmera, sincroniza RAW e publica metadata fiel por câmera.
|
CAM_A/rgb -> BGR uint8
|
||||||
|
CAM_B/re -> GRAY uint8
|
||||||
PREVIEW/MULTISPEC permanecem apenas para compatibilidade legada.
|
CAM_C/nir -> GRAY uint8
|
||||||
Um module_params produzido pelo assembler oficial exige RAW_BRUTO.
|
- O Client deve montar o tensor sem reaplicar homografia.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
PRODUCT_SCHEMA = "multispec_module_params_v3"
|
SENSOR_W = 1280
|
||||||
ASSEMBLY_SCHEMA = "multispec_module_params_assembly_v1"
|
SENSOR_H = 800
|
||||||
|
|
||||||
LEGACY_SENSOR_W = 1280
|
|
||||||
LEGACY_SENSOR_H = 800
|
|
||||||
|
|
||||||
PRODUCT_TOPOLOGY = {
|
|
||||||
"rgb": {"socket": "CAM_A", "allowed_sensors": ("OV9782", "AR0234")},
|
|
||||||
"re": {"socket": "CAM_B", "allowed_sensors": ("OV9282",)},
|
|
||||||
"nir": {"socket": "CAM_C", "allowed_sensors": ("OV9282",)},
|
|
||||||
}
|
|
||||||
|
|
||||||
SENSOR_PROFILES = {
|
|
||||||
"OV9782": {
|
|
||||||
"kind": "color",
|
|
||||||
"resolution_name": "THE_800_P",
|
|
||||||
"width": 1280,
|
|
||||||
"height": 800,
|
|
||||||
},
|
|
||||||
"AR0234": {
|
|
||||||
"kind": "color",
|
|
||||||
"resolution_name": "THE_1200_P",
|
|
||||||
"width": 1920,
|
|
||||||
"height": 1200,
|
|
||||||
},
|
|
||||||
"OV9282": {
|
|
||||||
"kind": "mono",
|
|
||||||
"resolution_name": "THE_800_P",
|
|
||||||
"width": 1280,
|
|
||||||
"height": 800,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
|
|
@ -77,75 +44,41 @@ class OakFcc3Manager:
|
||||||
capture_mode="AUTO",
|
capture_mode="AUTO",
|
||||||
raw_policy="allow_single",
|
raw_policy="allow_single",
|
||||||
roles=None,
|
roles=None,
|
||||||
sync_mode="best",
|
sync_mode=None,
|
||||||
hardware_sync_enabled=True,
|
hardware_sync_enabled=None,
|
||||||
frame_sync_master="CAM_A",
|
frame_sync_master=None,
|
||||||
sync_tolerance_ms=12.0,
|
sync_tolerance_ms=None,
|
||||||
buffer_size=8,
|
buffer_size=None,
|
||||||
only_camera=None,
|
only_camera=None,
|
||||||
mx_id=None,
|
mx_id=None,
|
||||||
module_calibration_json=None,
|
module_calibration_json=None,
|
||||||
module_params=None,
|
module_params=None,
|
||||||
require_product_contract=False,
|
|
||||||
imu_modo="rotation_vector",
|
imu_modo="rotation_vector",
|
||||||
imu_freq_hz=200,
|
imu_freq_hz=200,
|
||||||
):
|
):
|
||||||
self.hardware_sync_enabled = bool(hardware_sync_enabled)
|
self.fps = fps
|
||||||
self.frame_sync_master = str(frame_sync_master).upper()
|
|
||||||
self.fps = float(fps)
|
|
||||||
|
|
||||||
# width/height são apenas saída LEGADA. RAW_BRUTO usa resolução nativa.
|
# Para compatibilidade, mantemos width/height.
|
||||||
|
# No RAW_BRUTO isso não muda o sensor, pois usamos 800p fixo.
|
||||||
|
# No MULTISPEC isso representa a saída final alinhada da OAK.
|
||||||
self.width = int(width)
|
self.width = int(width)
|
||||||
self.height = int(height)
|
self.height = int(height)
|
||||||
self.size = (self.width, self.height)
|
self.size = (self.width, self.height)
|
||||||
|
|
||||||
|
self.sensor_width = self.SENSOR_W
|
||||||
|
self.sensor_height = self.SENSOR_H
|
||||||
|
|
||||||
self.frame_type = str(frame_type).upper()
|
self.frame_type = str(frame_type).upper()
|
||||||
self.output_dtype = output_dtype
|
self.output_dtype = output_dtype
|
||||||
self.capture_mode = str(capture_mode).upper()
|
self.capture_mode = capture_mode
|
||||||
self.raw_policy = str(raw_policy).lower()
|
self.raw_policy = raw_policy
|
||||||
self.only_camera = only_camera
|
self.only_camera = only_camera
|
||||||
|
|
||||||
self.module_calibration_json = module_calibration_json
|
self.roles = roles or {
|
||||||
self.module_params = (
|
"CAM_A": "rgb",
|
||||||
copy.deepcopy(module_params)
|
"CAM_B": "re",
|
||||||
if isinstance(module_params, dict)
|
"CAM_C": "nir",
|
||||||
else self._load_module_params(module_calibration_json)
|
}
|
||||||
)
|
|
||||||
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
|
|
||||||
self.require_product_contract = bool(require_product_contract)
|
|
||||||
|
|
||||||
self.product_contract = self._is_product_module_params()
|
|
||||||
self.expected_device_mx_id = self._expected_device_mx_id()
|
|
||||||
|
|
||||||
if self.product_contract:
|
|
||||||
self.roles = {
|
|
||||||
contract["socket"]: role
|
|
||||||
for role, contract in self.PRODUCT_TOPOLOGY.items()
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
self.roles = roles or {
|
|
||||||
"CAM_A": "rgb",
|
|
||||||
"CAM_B": "re",
|
|
||||||
"CAM_C": "nir",
|
|
||||||
}
|
|
||||||
|
|
||||||
self.expected_camera_hardware = self._resolve_expected_camera_hardware()
|
|
||||||
self.sensor_size_by_role = self._resolve_sensor_size_by_role()
|
|
||||||
|
|
||||||
rgb_size = self.sensor_size_by_role.get(
|
|
||||||
"rgb",
|
|
||||||
[self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
|
|
||||||
)
|
|
||||||
self.sensor_width = int(rgb_size[0])
|
|
||||||
self.sensor_height = int(rgb_size[1])
|
|
||||||
|
|
||||||
self.bayer_pattern = str(
|
|
||||||
(self.module_params or {}).get("bayer_pattern", "BGGR")
|
|
||||||
).upper()
|
|
||||||
|
|
||||||
self.sync_mode = str(sync_mode).lower()
|
|
||||||
self.sync_tolerance_ms = float(sync_tolerance_ms)
|
|
||||||
self.buffer_size = int(buffer_size)
|
|
||||||
|
|
||||||
self.mx_id = str(mx_id) if mx_id else None
|
self.mx_id = str(mx_id) if mx_id else None
|
||||||
self.dev_info = None
|
self.dev_info = None
|
||||||
|
|
@ -157,12 +90,14 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
self.imu_modo = self._validate_imu_modo(imu_modo)
|
self.imu_modo = self._validate_imu_modo(imu_modo)
|
||||||
self.imu_freq_hz = int(imu_freq_hz)
|
self.imu_freq_hz = int(imu_freq_hz)
|
||||||
|
|
||||||
if self.imu_freq_hz <= 0:
|
if self.imu_freq_hz <= 0:
|
||||||
raise ValueError(
|
raise ValueError(
|
||||||
f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}"
|
f"imu_freq_hz deve ser maior que zero: {self.imu_freq_hz}"
|
||||||
)
|
)
|
||||||
|
|
||||||
self.imu_sensor_type = None
|
self.imu_sensor_type = None
|
||||||
|
|
||||||
self.has_imu_pipeline = False
|
self.has_imu_pipeline = False
|
||||||
self.tem_imu = False
|
self.tem_imu = False
|
||||||
self.q_imu = None
|
self.q_imu = None
|
||||||
|
|
@ -170,18 +105,32 @@ class OakFcc3Manager:
|
||||||
self.running = False
|
self.running = False
|
||||||
self.frame_id = 0
|
self.frame_id = 0
|
||||||
|
|
||||||
|
# Serializa start/stop e leituras nativas de telemetria.
|
||||||
|
# Evita getChipTemperature/getUsbSpeed concorrendo com device.close().
|
||||||
self._device_lock = threading.RLock()
|
self._device_lock = threading.RLock()
|
||||||
self.control_queues = {}
|
self.control_queues = {}
|
||||||
self._last_raw_dims = {}
|
|
||||||
self.aligned_geometry = None
|
|
||||||
|
|
||||||
# Startup Profile entra ANTES do pipeline existir.
|
|
||||||
self.camera_controls = {
|
self.camera_controls = {
|
||||||
cam_id: self._default_controls_for_role(role)
|
cam_id: self._default_controls_for_role(role)
|
||||||
for cam_id, role in self.roles.items()
|
for cam_id, role in self.roles.items()
|
||||||
}
|
}
|
||||||
self._hydrate_camera_controls_from_module_params()
|
self._last_raw_dims = {}
|
||||||
self._validate_static_product_contract()
|
|
||||||
|
self.module_calibration_json = module_calibration_json
|
||||||
|
self.module_params = module_params if isinstance(module_params, dict) else self._load_module_params(module_calibration_json)
|
||||||
|
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
|
||||||
|
self.aligned_geometry = None
|
||||||
|
|
||||||
|
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
|
||||||
|
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
|
||||||
|
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
|
||||||
|
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
|
||||||
|
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
|
||||||
|
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
|
||||||
|
self.hardware_sync_enabled = bool(hardware_sync_enabled)
|
||||||
|
self.frame_sync_master = str(frame_sync_master)
|
||||||
|
self.sync_mode = str(sync_mode)
|
||||||
|
self.sync_tolerance_ms = float(sync_tolerance_ms)
|
||||||
|
self.buffer_size = int(buffer_size)
|
||||||
|
|
||||||
self.async_capture_enabled = True
|
self.async_capture_enabled = True
|
||||||
self.async_capture_mode = "latest" # latest | queue
|
self.async_capture_mode = "latest" # latest | queue
|
||||||
|
|
@ -228,327 +177,11 @@ class OakFcc3Manager:
|
||||||
# ============================================================
|
# ============================================================
|
||||||
|
|
||||||
def _load_module_params(self, path):
|
def _load_module_params(self, path):
|
||||||
if not path:
|
if not path or not os.path.isfile(path):
|
||||||
return {}
|
return {}
|
||||||
|
|
||||||
if not os.path.isfile(path):
|
|
||||||
raise FileNotFoundError(
|
|
||||||
f"module_params não encontrado: {path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
with open(path, "r", encoding="utf-8") as f:
|
with open(path, "r", encoding="utf-8") as f:
|
||||||
data = json.load(f)
|
return json.load(f)
|
||||||
|
|
||||||
if not isinstance(data, dict):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"module_params root deve ser dict/object: {path}"
|
|
||||||
)
|
|
||||||
|
|
||||||
return data
|
|
||||||
|
|
||||||
def _is_product_module_params(self):
|
|
||||||
mp = self.module_params or {}
|
|
||||||
assembly = mp.get("assembly_metadata", {}) or {}
|
|
||||||
|
|
||||||
return bool(
|
|
||||||
mp.get("schema") == self.PRODUCT_SCHEMA
|
|
||||||
and assembly.get("schema") == self.ASSEMBLY_SCHEMA
|
|
||||||
and isinstance(mp.get("camera_hardware"), dict)
|
|
||||||
and isinstance(mp.get("sensor_size_by_role"), dict)
|
|
||||||
and isinstance(mp.get("calibration_provenance"), dict)
|
|
||||||
)
|
|
||||||
|
|
||||||
def _expected_device_mx_id(self):
|
|
||||||
prov = (self.module_params or {}).get("calibration_provenance", {}) or {}
|
|
||||||
value = prov.get("device_mx_id")
|
|
||||||
return str(value) if value else None
|
|
||||||
|
|
||||||
def _normalize_size(self, value, *, label):
|
|
||||||
if not (isinstance(value, (list, tuple)) and len(value) == 2):
|
|
||||||
raise RuntimeError(f"{label} deve ser [W,H], recebido={value!r}")
|
|
||||||
|
|
||||||
w = int(value[0])
|
|
||||||
h = int(value[1])
|
|
||||||
|
|
||||||
if w <= 0 or h <= 0:
|
|
||||||
raise RuntimeError(f"{label} inválido: {value!r}")
|
|
||||||
|
|
||||||
return [w, h]
|
|
||||||
|
|
||||||
def _resolve_expected_camera_hardware(self):
|
|
||||||
if not self.product_contract:
|
|
||||||
return {}
|
|
||||||
|
|
||||||
raw = (self.module_params or {}).get("camera_hardware", {}) or {}
|
|
||||||
out = {}
|
|
||||||
|
|
||||||
for role, contract in self.PRODUCT_TOPOLOGY.items():
|
|
||||||
item = raw.get(role)
|
|
||||||
|
|
||||||
if not isinstance(item, dict):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"module_params.camera_hardware sem role={role}"
|
|
||||||
)
|
|
||||||
|
|
||||||
socket = str(item.get("socket") or "").upper()
|
|
||||||
sensor = str(item.get("sensor") or "").upper()
|
|
||||||
size = self._normalize_size(
|
|
||||||
item.get("size"),
|
|
||||||
label=f"camera_hardware.{role}.size",
|
|
||||||
)
|
|
||||||
|
|
||||||
if socket != contract["socket"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_hardware.{role}.socket={socket!r}, "
|
|
||||||
f"esperado={contract['socket']!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if sensor not in contract["allowed_sensors"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_hardware.{role}.sensor={sensor!r}, "
|
|
||||||
f"permitidos={contract['allowed_sensors']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
profile = self.SENSOR_PROFILES.get(sensor)
|
|
||||||
|
|
||||||
if profile is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Sensor sem profile de runtime: {sensor}"
|
|
||||||
)
|
|
||||||
|
|
||||||
native = [int(profile["width"]), int(profile["height"])]
|
|
||||||
|
|
||||||
if size != native:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_hardware.{role}.size={size} "
|
|
||||||
f"não corresponde ao nativo de {sensor}: {native}"
|
|
||||||
)
|
|
||||||
|
|
||||||
out[role] = {
|
|
||||||
"role": role,
|
|
||||||
"socket": socket,
|
|
||||||
"sensor": sensor,
|
|
||||||
"size": size,
|
|
||||||
"kind": profile["kind"],
|
|
||||||
"resolution_name": profile["resolution_name"],
|
|
||||||
}
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
def _resolve_sensor_size_by_role(self):
|
|
||||||
if self.product_contract:
|
|
||||||
raw = (self.module_params or {}).get("sensor_size_by_role", {}) or {}
|
|
||||||
out = {}
|
|
||||||
|
|
||||||
for role in ("rgb", "re", "nir"):
|
|
||||||
size = self._normalize_size(
|
|
||||||
raw.get(role),
|
|
||||||
label=f"sensor_size_by_role.{role}",
|
|
||||||
)
|
|
||||||
expected = self.expected_camera_hardware[role]["size"]
|
|
||||||
|
|
||||||
if size != expected:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"sensor_size_by_role.{role}={size} != "
|
|
||||||
f"camera_hardware={expected}"
|
|
||||||
)
|
|
||||||
|
|
||||||
out[role] = size
|
|
||||||
|
|
||||||
return out
|
|
||||||
|
|
||||||
return {
|
|
||||||
"rgb": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
|
|
||||||
"re": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
|
|
||||||
"nir": [self.LEGACY_SENSOR_W, self.LEGACY_SENSOR_H],
|
|
||||||
}
|
|
||||||
|
|
||||||
def _validate_static_product_contract(self):
|
|
||||||
if self.require_product_contract and not self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Contrato de produção obrigatório, mas o module_params "
|
|
||||||
"não foi gerado pelo assembler oficial."
|
|
||||||
)
|
|
||||||
|
|
||||||
if not self.product_contract:
|
|
||||||
return
|
|
||||||
|
|
||||||
if self.frame_type != "RAW_BRUTO":
|
|
||||||
raise RuntimeError(
|
|
||||||
"module_params de produção aceita somente frame_type=RAW_BRUTO. "
|
|
||||||
f"Recebido={self.frame_type!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.capture_mode not in ("TRIPLE", "AUTO"):
|
|
||||||
raise RuntimeError(
|
|
||||||
"Produto multiespectral exige capture_mode TRIPLE "
|
|
||||||
f"(ou AUTO com require_triple). Recebido={self.capture_mode!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.raw_policy != "require_triple":
|
|
||||||
raise RuntimeError(
|
|
||||||
"module_params de produção exige raw_policy='require_triple'. "
|
|
||||||
f"Recebido={self.raw_policy!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.only_camera is not None:
|
|
||||||
raise RuntimeError(
|
|
||||||
"only_camera não é permitido no contrato de produção."
|
|
||||||
)
|
|
||||||
|
|
||||||
if self.bayer_pattern not in ("RGGB", "BGGR", "GRBG", "GBRG"):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"bayer_pattern inválido no module_params: {self.bayer_pattern!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if (
|
|
||||||
self.mx_id is not None
|
|
||||||
and self.expected_device_mx_id is not None
|
|
||||||
and self.mx_id != self.expected_device_mx_id
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"MX ID solicitado diverge da calibração homologada: "
|
|
||||||
f"requested={self.mx_id}, calibrated={self.expected_device_mx_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _role_to_cam_id(self, role):
|
|
||||||
role = str(role).lower()
|
|
||||||
|
|
||||||
for cam_id, mapped_role in self.roles.items():
|
|
||||||
if str(mapped_role).lower() == role:
|
|
||||||
return cam_id
|
|
||||||
|
|
||||||
return None
|
|
||||||
|
|
||||||
def _hydrate_camera_controls_from_module_params(self):
|
|
||||||
settings = (self.module_params or {}).get("camera_settings", {}) or {}
|
|
||||||
|
|
||||||
if not isinstance(settings, dict):
|
|
||||||
if self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
"module_params de produção sem camera_settings."
|
|
||||||
)
|
|
||||||
return
|
|
||||||
|
|
||||||
for role, cfg in settings.items():
|
|
||||||
if not isinstance(cfg, dict):
|
|
||||||
continue
|
|
||||||
|
|
||||||
cam_id = self._role_to_cam_id(role)
|
|
||||||
|
|
||||||
if cam_id is None:
|
|
||||||
continue
|
|
||||||
|
|
||||||
state = self.camera_controls.setdefault(
|
|
||||||
cam_id,
|
|
||||||
self._default_controls_for_role(role),
|
|
||||||
)
|
|
||||||
|
|
||||||
for key in (
|
|
||||||
"ae_enable",
|
|
||||||
"awb_enable",
|
|
||||||
"exposure_time_us",
|
|
||||||
"analogue_gain",
|
|
||||||
"colour_gains",
|
|
||||||
):
|
|
||||||
if key in cfg:
|
|
||||||
state[key] = copy.deepcopy(cfg[key])
|
|
||||||
|
|
||||||
if self.product_contract:
|
|
||||||
for role in ("rgb", "re", "nir"):
|
|
||||||
cam_id = self._role_to_cam_id(role)
|
|
||||||
|
|
||||||
if cam_id is None or cam_id not in self.camera_controls:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"camera_settings não resolveu role={role}"
|
|
||||||
)
|
|
||||||
|
|
||||||
def _sensor_profile(self, sensor_name, role=None):
|
|
||||||
sensor = str(sensor_name or "").upper()
|
|
||||||
profile = self.SENSOR_PROFILES.get(sensor)
|
|
||||||
|
|
||||||
if profile is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Sensor não suportado pelo runtime: {sensor!r}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if role is not None:
|
|
||||||
role = str(role).lower()
|
|
||||||
expected_kind = "color" if role == "rgb" else "mono"
|
|
||||||
|
|
||||||
if profile["kind"] != expected_kind:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"Sensor {sensor} é {profile['kind']}, "
|
|
||||||
f"mas role={role} exige {expected_kind}."
|
|
||||||
)
|
|
||||||
|
|
||||||
return profile
|
|
||||||
|
|
||||||
def _feature_rows(self, features):
|
|
||||||
rows = []
|
|
||||||
|
|
||||||
for f in features:
|
|
||||||
socket = f.socket.name
|
|
||||||
sensor = str(f.sensorName or "").upper()
|
|
||||||
|
|
||||||
rows.append({
|
|
||||||
"socket": socket,
|
|
||||||
"sensor": sensor,
|
|
||||||
"width": int(getattr(f, "width", 0) or 0),
|
|
||||||
"height": int(getattr(f, "height", 0) or 0),
|
|
||||||
"role": self.roles.get(socket, "unknown"),
|
|
||||||
})
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def _validate_connected_hardware(self, features):
|
|
||||||
rows = self._feature_rows(features)
|
|
||||||
by_socket = {row["socket"]: row for row in rows}
|
|
||||||
|
|
||||||
if not self.product_contract:
|
|
||||||
return rows
|
|
||||||
|
|
||||||
if (
|
|
||||||
self.expected_device_mx_id is not None
|
|
||||||
and self.mx_id is not None
|
|
||||||
and self.mx_id != self.expected_device_mx_id
|
|
||||||
):
|
|
||||||
raise RuntimeError(
|
|
||||||
"OAK conectada não corresponde ao módulo calibrado: "
|
|
||||||
f"connected={self.mx_id}, calibrated={self.expected_device_mx_id}"
|
|
||||||
)
|
|
||||||
|
|
||||||
errors = []
|
|
||||||
|
|
||||||
for role, expected in self.expected_camera_hardware.items():
|
|
||||||
row = by_socket.get(expected["socket"])
|
|
||||||
|
|
||||||
if row is None:
|
|
||||||
errors.append(f"{role}: {expected['socket']} ausente")
|
|
||||||
continue
|
|
||||||
|
|
||||||
if row["sensor"] != expected["sensor"]:
|
|
||||||
errors.append(
|
|
||||||
f"{role}: {expected['socket']} sensor={row['sensor']}, "
|
|
||||||
f"esperado={expected['sensor']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if row["width"] > 0 and row["height"] > 0:
|
|
||||||
advertised = [row["width"], row["height"]]
|
|
||||||
|
|
||||||
if advertised != expected["size"]:
|
|
||||||
errors.append(
|
|
||||||
f"{role}: {expected['socket']}/{row['sensor']} anunciou "
|
|
||||||
f"{advertised}, esperado={expected['size']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
if errors:
|
|
||||||
raise RuntimeError(
|
|
||||||
"Hardware OAK não corresponde ao module_params homologado:\n - "
|
|
||||||
+ "\n - ".join(errors)
|
|
||||||
)
|
|
||||||
|
|
||||||
return rows
|
|
||||||
|
|
||||||
def _default_controls_for_role(self, role: str):
|
def _default_controls_for_role(self, role: str):
|
||||||
role = str(role).lower()
|
role = str(role).lower()
|
||||||
|
|
@ -579,31 +212,12 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
with dai.Device(dev_info) as dev:
|
with dai.Device(dev_info) as dev:
|
||||||
result = []
|
result = []
|
||||||
features = dev.getConnectedCameraFeatures()
|
for f in dev.getConnectedCameraFeatures():
|
||||||
|
|
||||||
for row in self._feature_rows(features):
|
|
||||||
role = str(row.get("role", "unknown")).lower()
|
|
||||||
expected = self.expected_camera_hardware.get(role)
|
|
||||||
|
|
||||||
result.append({
|
result.append({
|
||||||
**row,
|
"socket": f.socket.name,
|
||||||
"expected": copy.deepcopy(expected),
|
"sensor": f.sensorName,
|
||||||
"matches_product_contract": (
|
"role": self.roles.get(f.socket.name, "unknown"),
|
||||||
None
|
|
||||||
if not self.product_contract
|
|
||||||
else bool(
|
|
||||||
expected is not None
|
|
||||||
and row["socket"] == expected["socket"]
|
|
||||||
and row["sensor"] == expected["sensor"]
|
|
||||||
and (
|
|
||||||
row["width"] <= 0
|
|
||||||
or row["height"] <= 0
|
|
||||||
or [row["width"], row["height"]] == expected["size"]
|
|
||||||
)
|
|
||||||
)
|
|
||||||
),
|
|
||||||
})
|
})
|
||||||
|
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def _device_id_from_info(self, dev_info):
|
def _device_id_from_info(self, dev_info):
|
||||||
|
|
@ -677,48 +291,35 @@ class OakFcc3Manager:
|
||||||
|
|
||||||
def _create_camera_node_classic(self, socket, sensor_name: str, role: str):
|
def _create_camera_node_classic(self, socket, sensor_name: str, role: str):
|
||||||
"""
|
"""
|
||||||
RAW/PREVIEW sensor-aware.
|
Fluxo clássico.
|
||||||
|
|
||||||
Produto:
|
RGB/OV9782:
|
||||||
OV9782 -> ColorCamera THE_800_P 1280x800
|
ColorCamera raw para RAW_BRUTO.
|
||||||
AR0234 -> ColorCamera THE_1200_P 1920x1200
|
|
||||||
OV9282 -> MonoCamera THE_800_P 1280x800
|
|
||||||
|
|
||||||
No produto não existe fallback silencioso de resolução.
|
MONO/OV9282:
|
||||||
|
MonoCamera raw quando disponível.
|
||||||
"""
|
"""
|
||||||
sensor = str(sensor_name or "").upper()
|
sensor_name_u = str(sensor_name or "").upper()
|
||||||
role = str(role or "").lower()
|
role_u = str(role or "").lower()
|
||||||
|
|
||||||
if self.product_contract:
|
is_rgb = (
|
||||||
profile = self._sensor_profile(sensor, role=role)
|
role_u == "rgb"
|
||||||
else:
|
or "OV9782" in sensor_name_u
|
||||||
profile = self.SENSOR_PROFILES.get(sensor)
|
or socket == dai.CameraBoardSocket.CAM_A
|
||||||
|
)
|
||||||
|
|
||||||
if profile is None:
|
if is_rgb:
|
||||||
profile = {
|
|
||||||
"kind": "color" if role == "rgb" else "mono",
|
|
||||||
"resolution_name": "THE_800_P",
|
|
||||||
"width": self.LEGACY_SENSOR_W,
|
|
||||||
"height": self.LEGACY_SENSOR_H,
|
|
||||||
}
|
|
||||||
|
|
||||||
if profile["kind"] == "color":
|
|
||||||
cam = self.pipeline.createColorCamera()
|
cam = self.pipeline.createColorCamera()
|
||||||
cam.setBoardSocket(socket)
|
cam.setBoardSocket(socket)
|
||||||
|
|
||||||
resolution = getattr(
|
try:
|
||||||
dai.ColorCameraProperties.SensorResolution,
|
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_800_P)
|
||||||
profile["resolution_name"],
|
except Exception:
|
||||||
None,
|
try:
|
||||||
)
|
cam.setResolution(dai.ColorCameraProperties.SensorResolution.THE_1080_P)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if resolution is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"DepthAI não expõe ColorCamera "
|
|
||||||
f"{profile['resolution_name']} para {sensor}."
|
|
||||||
)
|
|
||||||
|
|
||||||
cam.setResolution(resolution)
|
|
||||||
cam.setInterleaved(False)
|
cam.setInterleaved(False)
|
||||||
cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB)
|
cam.setColorOrder(dai.ColorCameraProperties.ColorOrder.RGB)
|
||||||
cam.setFps(float(self.fps))
|
cam.setFps(float(self.fps))
|
||||||
|
|
@ -730,42 +331,31 @@ class OakFcc3Manager:
|
||||||
except Exception:
|
except Exception:
|
||||||
return cam, cam.preview
|
return cam, cam.preview
|
||||||
|
|
||||||
if not hasattr(cam, "raw"):
|
|
||||||
raise RuntimeError(
|
|
||||||
f"ColorCamera {sensor} não expõe raw output."
|
|
||||||
)
|
|
||||||
|
|
||||||
return cam, cam.raw
|
return cam, cam.raw
|
||||||
|
|
||||||
mono = self.pipeline.create(dai.node.MonoCamera)
|
mono = self.pipeline.create(dai.node.MonoCamera)
|
||||||
mono.setBoardSocket(socket)
|
mono.setBoardSocket(socket)
|
||||||
|
|
||||||
resolution = getattr(
|
try:
|
||||||
dai.MonoCameraProperties.SensorResolution,
|
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_800_P)
|
||||||
profile["resolution_name"],
|
except Exception:
|
||||||
None,
|
try:
|
||||||
)
|
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_720_P)
|
||||||
|
except Exception:
|
||||||
|
try:
|
||||||
|
mono.setResolution(dai.MonoCameraProperties.SensorResolution.THE_400_P)
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
if resolution is None:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"DepthAI não expõe MonoCamera "
|
|
||||||
f"{profile['resolution_name']} para {sensor}."
|
|
||||||
)
|
|
||||||
|
|
||||||
mono.setResolution(resolution)
|
|
||||||
mono.setFps(float(self.fps))
|
mono.setFps(float(self.fps))
|
||||||
|
|
||||||
if self._is_preview_mode():
|
if self._is_preview_mode():
|
||||||
return mono, mono.out
|
return mono, mono.out
|
||||||
|
|
||||||
if not hasattr(mono, "raw"):
|
if hasattr(mono, "raw"):
|
||||||
if self.product_contract:
|
return mono, mono.raw
|
||||||
raise RuntimeError(
|
|
||||||
f"MonoCamera {sensor} não expõe raw output."
|
|
||||||
)
|
|
||||||
return mono, mono.out
|
|
||||||
|
|
||||||
return mono, mono.raw
|
return mono, mono.out
|
||||||
|
|
||||||
def _create_imu_node(self, pipeline):
|
def _create_imu_node(self, pipeline):
|
||||||
self.has_imu_pipeline = False
|
self.has_imu_pipeline = False
|
||||||
|
|
@ -1306,13 +896,6 @@ class OakFcc3Manager:
|
||||||
self.pipeline = dai.Pipeline()
|
self.pipeline = dai.Pipeline()
|
||||||
|
|
||||||
features = self.device.getConnectedCameraFeatures()
|
features = self.device.getConnectedCameraFeatures()
|
||||||
self._validate_connected_hardware(features)
|
|
||||||
|
|
||||||
if self.product_contract and self._is_multispec_mode():
|
|
||||||
raise RuntimeError(
|
|
||||||
"MULTISPEC alinhado na OAK é legado e não faz parte do "
|
|
||||||
"contrato de produção. Use RAW_BRUTO."
|
|
||||||
)
|
|
||||||
|
|
||||||
self.queues.clear()
|
self.queues.clear()
|
||||||
self.buffers.clear()
|
self.buffers.clear()
|
||||||
|
|
@ -1360,44 +943,11 @@ class OakFcc3Manager:
|
||||||
self.buffers[cam_id] = deque(maxlen=self.buffer_size)
|
self.buffers[cam_id] = deque(maxlen=self.buffer_size)
|
||||||
self.control_queues[cam_id] = None
|
self.control_queues[cam_id] = None
|
||||||
|
|
||||||
sensor_u = str(f.sensorName or "").upper()
|
|
||||||
if self.product_contract or sensor_u in self.SENSOR_PROFILES:
|
|
||||||
profile = self._sensor_profile(sensor_u, role=role)
|
|
||||||
else:
|
|
||||||
profile = {
|
|
||||||
"kind": "unknown",
|
|
||||||
"width": int(getattr(f, "width", 0) or self.LEGACY_SENSOR_W),
|
|
||||||
"height": int(getattr(f, "height", 0) or self.LEGACY_SENSOR_H),
|
|
||||||
"resolution_name": None,
|
|
||||||
}
|
|
||||||
|
|
||||||
self.camera_info[cam_id] = {
|
self.camera_info[cam_id] = {
|
||||||
"id": cam_id,
|
"id": cam_id,
|
||||||
"socket": socket_name,
|
"socket": socket_name,
|
||||||
"sensor": sensor_u,
|
"sensor": f.sensorName,
|
||||||
"role": role,
|
"role": role,
|
||||||
"native_width": int(profile["width"]),
|
|
||||||
"native_height": int(profile["height"]),
|
|
||||||
"native_size": [
|
|
||||||
int(profile["width"]),
|
|
||||||
int(profile["height"]),
|
|
||||||
],
|
|
||||||
"sensor_kind": profile["kind"],
|
|
||||||
"resolution_mode": profile.get("resolution_name"),
|
|
||||||
"bayer_pattern": (
|
|
||||||
self.bayer_pattern
|
|
||||||
if str(role).lower() == "rgb"
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
"product_expected": (
|
|
||||||
copy.deepcopy(
|
|
||||||
self.expected_camera_hardware.get(
|
|
||||||
str(role).lower()
|
|
||||||
)
|
|
||||||
)
|
|
||||||
if self.product_contract
|
|
||||||
else None
|
|
||||||
),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
self._validate_capture_mode()
|
self._validate_capture_mode()
|
||||||
|
|
@ -1599,14 +1149,6 @@ class OakFcc3Manager:
|
||||||
"height": self.height,
|
"height": self.height,
|
||||||
"sensor_width": self.sensor_width,
|
"sensor_width": self.sensor_width,
|
||||||
"sensor_height": self.sensor_height,
|
"sensor_height": self.sensor_height,
|
||||||
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
|
|
||||||
"camera_hardware_expected": copy.deepcopy(self.expected_camera_hardware),
|
|
||||||
"bayer_pattern": self.bayer_pattern,
|
|
||||||
"product_contract": bool(self.product_contract),
|
|
||||||
"require_product_contract": bool(self.require_product_contract),
|
|
||||||
"manager_version": OAK_FCC3_MANAGER_VERSION,
|
|
||||||
"module_params_schema": (self.module_params or {}).get("schema"),
|
|
||||||
"module_calibration_json": self.module_calibration_json,
|
|
||||||
"frame_type": self.frame_type,
|
"frame_type": self.frame_type,
|
||||||
"output_dtype": self.output_dtype,
|
"output_dtype": self.output_dtype,
|
||||||
"capture_mode": self.capture_mode,
|
"capture_mode": self.capture_mode,
|
||||||
|
|
@ -2184,32 +1726,6 @@ class OakFcc3Manager:
|
||||||
item["shape"] = list(arr.shape)
|
item["shape"] = list(arr.shape)
|
||||||
item["dtype"] = str(arr.dtype)
|
item["dtype"] = str(arr.dtype)
|
||||||
item["packed"] = True
|
item["packed"] = True
|
||||||
item["native_width"] = int(
|
|
||||||
item.get("native_width", item["width"])
|
|
||||||
)
|
|
||||||
item["native_height"] = int(
|
|
||||||
item.get("native_height", item["height"])
|
|
||||||
)
|
|
||||||
item["native_size"] = [
|
|
||||||
item["native_width"],
|
|
||||||
item["native_height"],
|
|
||||||
]
|
|
||||||
|
|
||||||
if str(item.get("role", "")).lower() == "rgb":
|
|
||||||
item["bayer_pattern"] = self.bayer_pattern
|
|
||||||
|
|
||||||
if self.product_contract:
|
|
||||||
role = str(item.get("role", "")).lower()
|
|
||||||
expected = self.expected_camera_hardware.get(role)
|
|
||||||
|
|
||||||
if expected is not None:
|
|
||||||
actual = [int(item["width"]), int(item["height"])]
|
|
||||||
|
|
||||||
if actual != expected["size"]:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{cam_id}/{role}: RAW frame {actual} != "
|
|
||||||
f"hardware homologado {expected['size']}"
|
|
||||||
)
|
|
||||||
|
|
||||||
camera_info[cam_id] = item
|
camera_info[cam_id] = item
|
||||||
|
|
||||||
|
|
@ -2217,18 +1733,6 @@ class OakFcc3Manager:
|
||||||
"frame_id": self.frame_id,
|
"frame_id": self.frame_id,
|
||||||
"backend": "oak_fcc3",
|
"backend": "oak_fcc3",
|
||||||
"frame_type": self.frame_type,
|
"frame_type": self.frame_type,
|
||||||
"product_contract": bool(self.product_contract),
|
|
||||||
"module_params_schema": (self.module_params or {}).get("schema"),
|
|
||||||
"module_calibration_json": self.module_calibration_json,
|
|
||||||
"device_mx_id": self.mx_id,
|
|
||||||
"reference_camera": "rgb",
|
|
||||||
"sensor_size_by_role": copy.deepcopy(self.sensor_size_by_role),
|
|
||||||
"camera_hardware": copy.deepcopy(
|
|
||||||
self.expected_camera_hardware
|
|
||||||
if self.product_contract
|
|
||||||
else {}
|
|
||||||
),
|
|
||||||
"bayer_pattern": self.bayer_pattern,
|
|
||||||
"capture_mode": self.capture_mode,
|
"capture_mode": self.capture_mode,
|
||||||
"output_dtype": self.output_dtype,
|
"output_dtype": self.output_dtype,
|
||||||
"dtype": self.output_dtype,
|
"dtype": self.output_dtype,
|
||||||
|
|
@ -2417,65 +1921,18 @@ class OakFcc3Manager:
|
||||||
return result
|
return result
|
||||||
|
|
||||||
def apply_initial_camera_controls_to_node(self, cam, cam_id):
|
def apply_initial_camera_controls_to_node(self, cam, cam_id):
|
||||||
"""
|
|
||||||
Aplica Startup Profile antes de startPipeline().
|
|
||||||
|
|
||||||
O Client pode reaplicar depois do start como confirmação, mas os
|
|
||||||
primeiros frames já nascem sob a política homologada.
|
|
||||||
"""
|
|
||||||
ctrl_state = self.camera_controls.get(cam_id, {})
|
ctrl_state = self.camera_controls.get(cam_id, {})
|
||||||
|
|
||||||
ae = bool(ctrl_state.get("ae_enable", False))
|
ae = bool(ctrl_state.get("ae_enable", False))
|
||||||
awb = bool(ctrl_state.get("awb_enable", False))
|
|
||||||
exp_us = int(ctrl_state.get("exposure_time_us") or 15000)
|
exp_us = int(ctrl_state.get("exposure_time_us") or 15000)
|
||||||
gain = float(ctrl_state.get("analogue_gain") or 1.0)
|
gain = float(ctrl_state.get("analogue_gain") or 1.0)
|
||||||
|
|
||||||
if ae:
|
if not ae:
|
||||||
fn = getattr(cam.initialControl, "setAutoExposureEnable", None)
|
|
||||||
|
|
||||||
if callable(fn):
|
|
||||||
fn()
|
|
||||||
elif self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{cam_id}: DepthAI sem setAutoExposureEnable no initialControl."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
cam.initialControl.setManualExposure(
|
cam.initialControl.setManualExposure(
|
||||||
exp_us,
|
exp_us,
|
||||||
self._gain_to_iso(gain),
|
self._gain_to_iso(gain),
|
||||||
)
|
)
|
||||||
|
|
||||||
role = str(self.roles.get(cam_id, "")).lower()
|
|
||||||
|
|
||||||
if role == "rgb":
|
|
||||||
if awb:
|
|
||||||
fn = getattr(
|
|
||||||
cam.initialControl,
|
|
||||||
"setAutoWhiteBalanceLock",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
|
|
||||||
if callable(fn):
|
|
||||||
fn(False)
|
|
||||||
else:
|
|
||||||
try:
|
|
||||||
cam.initialControl.setAutoWhiteBalanceMode(
|
|
||||||
dai.CameraControl.AutoWhiteBalanceMode.AUTO
|
|
||||||
)
|
|
||||||
except Exception:
|
|
||||||
if self.product_contract:
|
|
||||||
raise RuntimeError(
|
|
||||||
f"{cam_id}: não foi possível habilitar AWB inicial."
|
|
||||||
)
|
|
||||||
else:
|
|
||||||
fn = getattr(
|
|
||||||
cam.initialControl,
|
|
||||||
"setAutoWhiteBalanceLock",
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if callable(fn):
|
|
||||||
fn(True)
|
|
||||||
|
|
||||||
def _send_control(self, cam_id, ctrl):
|
def _send_control(self, cam_id, ctrl):
|
||||||
if cam_id not in self.control_queues:
|
if cam_id not in self.control_queues:
|
||||||
raise RuntimeError(f"Fila de controle não existe para {cam_id}")
|
raise RuntimeError(f"Fila de controle não existe para {cam_id}")
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
|
|
@ -13,7 +13,8 @@ arquivo que só deve existir ao final da linha de calibração.
|
||||||
Responsabilidades:
|
Responsabilidades:
|
||||||
- selecionar e descobrir o dispositivo DepthAI;
|
- selecionar e descobrir o dispositivo DepthAI;
|
||||||
- validar CAM_A RGB + CAM_B RE + CAM_C NIR;
|
- validar CAM_A RGB + CAM_B RE + CAM_C NIR;
|
||||||
- resolver/confirmar Bayer da RGB usando preview RAW10;
|
- resolver/confirmar Bayer da RGB usando o RawProcessorPreview oficial;
|
||||||
|
- confirmar a orientação física/canônica das três câmeras;
|
||||||
- registrar o domínio de decode RGB escolhido;
|
- registrar o domínio de decode RGB escolhido;
|
||||||
- criar a pasta oficial do perfil (ex.: calibration/mp_ar0234);
|
- criar a pasta oficial do perfil (ex.: calibration/mp_ar0234);
|
||||||
- declarar caminhos oficiais para todas as calibrações seguintes;
|
- declarar caminhos oficiais para todas as calibrações seguintes;
|
||||||
|
|
@ -32,6 +33,11 @@ Exemplos:
|
||||||
|
|
||||||
Teclas no preview:
|
Teclas no preview:
|
||||||
B alterna Bayer RGB
|
B alterna Bayer RGB
|
||||||
|
1/2/3 seleciona RGB/RE/NIR
|
||||||
|
R gira a câmera selecionada 90 graus no sentido horário
|
||||||
|
H alterna flip horizontal da câmera selecionada
|
||||||
|
V alterna flip vertical da câmera selecionada
|
||||||
|
D restaura orientação padrão da câmera selecionada
|
||||||
S salva snapshot
|
S salva snapshot
|
||||||
ENTER confirma e promove o profile
|
ENTER confirma e promove o profile
|
||||||
Q/ESC cancela sem alterar o profile ativo
|
Q/ESC cancela sem alterar o profile ativo
|
||||||
|
|
@ -55,6 +61,8 @@ import cv2
|
||||||
import depthai as dai
|
import depthai as dai
|
||||||
import numpy as np
|
import numpy as np
|
||||||
|
|
||||||
|
from core.raw_processor_preview import RawProcessorPreview
|
||||||
|
|
||||||
|
|
||||||
SCHEMA = "multispec_module_profile_v1"
|
SCHEMA = "multispec_module_profile_v1"
|
||||||
SELECTOR_SCHEMA = "multispec_active_module_profile_v1"
|
SELECTOR_SCHEMA = "multispec_active_module_profile_v1"
|
||||||
|
|
@ -256,6 +264,8 @@ class CameraSpec:
|
||||||
resolution_name: str
|
resolution_name: str
|
||||||
is_color: bool
|
is_color: bool
|
||||||
preview_rotate_deg: int
|
preview_rotate_deg: int
|
||||||
|
preview_flip_horizontal: bool
|
||||||
|
preview_flip_vertical: bool
|
||||||
stream_name: str
|
stream_name: str
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -302,6 +312,8 @@ def validate_and_build_specs(rows: list[dict]) -> Dict[str, CameraSpec]:
|
||||||
resolution_name=str(cfg["resolution_enum"]),
|
resolution_name=str(cfg["resolution_enum"]),
|
||||||
is_color=(role == "rgb"),
|
is_color=(role == "rgb"),
|
||||||
preview_rotate_deg=int(cfg["preview_rotate_deg"]),
|
preview_rotate_deg=int(cfg["preview_rotate_deg"]),
|
||||||
|
preview_flip_horizontal=False,
|
||||||
|
preview_flip_vertical=False,
|
||||||
stream_name=f"profile_{role}",
|
stream_name=f"profile_{role}",
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -364,77 +376,12 @@ def build_preview_pipeline(specs: Dict[str, CameraSpec], fps: float):
|
||||||
return pipeline
|
return pipeline
|
||||||
|
|
||||||
|
|
||||||
def unpack_raw10(data, width: int, height: int, stride: Optional[int] = None):
|
def rgb_raw_preview(
|
||||||
width = int(width)
|
pkt,
|
||||||
height = int(height)
|
spec: CameraSpec,
|
||||||
if width <= 0 or height <= 0 or width % 4:
|
processor: RawProcessorPreview,
|
||||||
raise ValueError(f"Dimensão RAW10 inválida: {width}x{height}")
|
):
|
||||||
|
"""Preview RGB oficial; não mantém um segundo mini-ISP neste script."""
|
||||||
raw = np.asarray(data, dtype=np.uint8).reshape(-1)
|
|
||||||
payload_bytes = (width // 4) * 5
|
|
||||||
|
|
||||||
candidates = []
|
|
||||||
if stride is not None:
|
|
||||||
try:
|
|
||||||
candidates.append(int(stride))
|
|
||||||
except Exception:
|
|
||||||
pass
|
|
||||||
if height > 0 and raw.size % height == 0:
|
|
||||||
candidates.append(raw.size // height)
|
|
||||||
candidates.append(payload_bytes)
|
|
||||||
|
|
||||||
row_stride = next(
|
|
||||||
(x for x in candidates if x >= payload_bytes and x * height <= raw.size),
|
|
||||||
None,
|
|
||||||
)
|
|
||||||
if row_stride is None:
|
|
||||||
raise ValueError(
|
|
||||||
f"RAW10 curto: bytes={raw.size}, payload mínimo={payload_bytes*height}"
|
|
||||||
)
|
|
||||||
|
|
||||||
rows = raw[: row_stride * height].reshape(height, row_stride)
|
|
||||||
groups = rows[:, :payload_bytes].reshape(height, width // 4, 5)
|
|
||||||
out = np.empty((height, width // 4, 4), dtype=np.uint16)
|
|
||||||
|
|
||||||
out[:, :, 0] = (
|
|
||||||
groups[:, :, 0].astype(np.uint16) << 2
|
|
||||||
) | (groups[:, :, 4] & 0x03)
|
|
||||||
out[:, :, 1] = (
|
|
||||||
groups[:, :, 1].astype(np.uint16) << 2
|
|
||||||
) | ((groups[:, :, 4] >> 2) & 0x03)
|
|
||||||
out[:, :, 2] = (
|
|
||||||
groups[:, :, 2].astype(np.uint16) << 2
|
|
||||||
) | ((groups[:, :, 4] >> 4) & 0x03)
|
|
||||||
out[:, :, 3] = (
|
|
||||||
groups[:, :, 3].astype(np.uint16) << 2
|
|
||||||
) | ((groups[:, :, 4] >> 6) & 0x03)
|
|
||||||
|
|
||||||
return np.ascontiguousarray(out.reshape(height, width))
|
|
||||||
|
|
||||||
|
|
||||||
def bayer_code(pattern: str, algorithm: str):
|
|
||||||
pattern = str(pattern).upper()
|
|
||||||
algorithm = str(algorithm).lower()
|
|
||||||
|
|
||||||
if algorithm == "ea":
|
|
||||||
table = {
|
|
||||||
"BGGR": cv2.COLOR_BayerRG2RGB_EA,
|
|
||||||
"RGGB": cv2.COLOR_BayerBG2RGB_EA,
|
|
||||||
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
|
|
||||||
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
|
|
||||||
}
|
|
||||||
else:
|
|
||||||
table = {
|
|
||||||
"BGGR": cv2.COLOR_BayerRG2RGB,
|
|
||||||
"RGGB": cv2.COLOR_BayerBG2RGB,
|
|
||||||
"GRBG": cv2.COLOR_BayerGR2RGB,
|
|
||||||
"GBRG": cv2.COLOR_BayerGB2RGB,
|
|
||||||
}
|
|
||||||
|
|
||||||
return table[pattern]
|
|
||||||
|
|
||||||
|
|
||||||
def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
|
|
||||||
raw_type = str(pkt.getType()).upper()
|
raw_type = str(pkt.getType()).upper()
|
||||||
if "PACK10" not in raw_type and "RAW10" not in raw_type:
|
if "PACK10" not in raw_type and "RAW10" not in raw_type:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -454,31 +401,50 @@ def rgb_raw_preview(pkt, spec: CameraSpec, pattern: str, algorithm: str):
|
||||||
except Exception:
|
except Exception:
|
||||||
stride = None
|
stride = None
|
||||||
|
|
||||||
raw16 = unpack_raw10(pkt.getData(), width, height, stride)
|
packed_data = np.asarray(pkt.getData(), dtype=np.uint8).reshape(-1)
|
||||||
rgb16 = cv2.cvtColor(raw16, bayer_code(pattern, algorithm))
|
payload_per_row = (width // 4) * 5
|
||||||
rgb = np.clip(rgb16.astype(np.float32) / 1023.0, 0.0, 1.0)
|
if (
|
||||||
|
stride is None
|
||||||
|
or stride < payload_per_row
|
||||||
|
or stride * height > packed_data.size
|
||||||
|
):
|
||||||
|
# Algumas versões do DepthAI reportam getStride() no domínio dos
|
||||||
|
# pixels, não dos bytes PACK10. Nesse caso o tamanho real vence.
|
||||||
|
stride = None
|
||||||
|
|
||||||
# Stretch comum aos canais: melhora a inspeção sem alterar o balanço RGB.
|
raw16 = processor.unpack_raw10_packed(
|
||||||
sample = rgb[::8, ::8]
|
packed_data,
|
||||||
lo = float(np.percentile(sample, 0.5))
|
stride=stride,
|
||||||
hi = float(np.percentile(sample, 99.5))
|
)
|
||||||
if hi <= lo + 1e-6:
|
return processor.raw16_to_preview_bgr(raw16)
|
||||||
hi = lo + 1e-6
|
|
||||||
rgb = np.clip((rgb - lo) / (hi - lo), 0.0, 1.0)
|
|
||||||
|
|
||||||
# OpenCV exibe BGR.
|
|
||||||
return np.ascontiguousarray((rgb[:, :, ::-1] * 255.0).astype(np.uint8))
|
|
||||||
|
|
||||||
|
|
||||||
def apply_preview_orientation(img: np.ndarray, rotate_deg: int):
|
def normalize_orientation(cfg: dict) -> dict:
|
||||||
rotate_deg = int(rotate_deg) % 360
|
rotate_deg = int((cfg or {}).get("rotate_deg", 0)) % 360
|
||||||
|
if rotate_deg not in (0, 90, 180, 270):
|
||||||
|
raise ValueError(f"Rotação inválida: {rotate_deg}")
|
||||||
|
return {
|
||||||
|
"rotate_deg": rotate_deg,
|
||||||
|
"flip_horizontal": bool((cfg or {}).get("flip_horizontal", False)),
|
||||||
|
"flip_vertical": bool((cfg or {}).get("flip_vertical", False)),
|
||||||
|
"source": str((cfg or {}).get("source") or "unknown"),
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def apply_preview_orientation(img: np.ndarray, cfg: dict):
|
||||||
|
cfg = normalize_orientation(cfg)
|
||||||
|
rotate_deg = cfg["rotate_deg"]
|
||||||
if rotate_deg == 90:
|
if rotate_deg == 90:
|
||||||
return cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
||||||
if rotate_deg == 180:
|
elif rotate_deg == 180:
|
||||||
return cv2.rotate(img, cv2.ROTATE_180)
|
img = cv2.rotate(img, cv2.ROTATE_180)
|
||||||
if rotate_deg == 270:
|
elif rotate_deg == 270:
|
||||||
return cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||||
return img
|
if cfg["flip_horizontal"]:
|
||||||
|
img = cv2.flip(img, 1)
|
||||||
|
if cfg["flip_vertical"]:
|
||||||
|
img = cv2.flip(img, 0)
|
||||||
|
return np.ascontiguousarray(img)
|
||||||
|
|
||||||
|
|
||||||
def overlay(img, lines, x=12, y=24, scale=0.48, step=21):
|
def overlay(img, lines, x=12, y=24, scale=0.48, step=21):
|
||||||
|
|
@ -499,6 +465,7 @@ def preview_and_confirm(
|
||||||
dev_info,
|
dev_info,
|
||||||
specs: Dict[str, CameraSpec],
|
specs: Dict[str, CameraSpec],
|
||||||
initial_bayer: str,
|
initial_bayer: str,
|
||||||
|
initial_orientation: Dict[str, dict],
|
||||||
algorithm: str,
|
algorithm: str,
|
||||||
fps: float,
|
fps: float,
|
||||||
panel_width: int,
|
panel_width: int,
|
||||||
|
|
@ -506,8 +473,25 @@ def preview_and_confirm(
|
||||||
snapshots_dir: Path,
|
snapshots_dir: Path,
|
||||||
):
|
):
|
||||||
pattern = initial_bayer
|
pattern = initial_bayer
|
||||||
|
orientation = {
|
||||||
|
role: normalize_orientation(initial_orientation[role])
|
||||||
|
for role in ROLES
|
||||||
|
}
|
||||||
|
orientation_defaults = {
|
||||||
|
role: dict(orientation[role])
|
||||||
|
for role in ROLES
|
||||||
|
}
|
||||||
|
selected_role = "rgb"
|
||||||
|
preview_processors = {
|
||||||
|
candidate: RawProcessorPreview(
|
||||||
|
sensor_width=specs["rgb"].width,
|
||||||
|
sensor_height=specs["rgb"].height,
|
||||||
|
bayer_pattern=candidate,
|
||||||
|
)
|
||||||
|
for candidate in BAYER_PATTERNS
|
||||||
|
}
|
||||||
pipeline = build_preview_pipeline(specs, fps)
|
pipeline = build_preview_pipeline(specs, fps)
|
||||||
window = "Module Profile - Bayer Confirmation"
|
window = "Module Profile - Physical Sanity Check"
|
||||||
cv2.namedWindow(window, cv2.WINDOW_NORMAL)
|
cv2.namedWindow(window, cv2.WINDOW_NORMAL)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
|
|
@ -538,7 +522,11 @@ def preview_and_confirm(
|
||||||
spec = specs[role]
|
spec = specs[role]
|
||||||
pkt = latest[role]
|
pkt = latest[role]
|
||||||
if role == "rgb":
|
if role == "rgb":
|
||||||
view = rgb_raw_preview(pkt, spec, pattern, algorithm)
|
view = rgb_raw_preview(
|
||||||
|
pkt,
|
||||||
|
spec,
|
||||||
|
preview_processors[pattern],
|
||||||
|
)
|
||||||
else:
|
else:
|
||||||
view = pkt.getCvFrame()
|
view = pkt.getCvFrame()
|
||||||
if view.ndim == 2:
|
if view.ndim == 2:
|
||||||
|
|
@ -546,7 +534,7 @@ def preview_and_confirm(
|
||||||
|
|
||||||
view = apply_preview_orientation(
|
view = apply_preview_orientation(
|
||||||
view,
|
view,
|
||||||
spec.preview_rotate_deg,
|
orientation[role],
|
||||||
)
|
)
|
||||||
panel = cv2.resize(
|
panel = cv2.resize(
|
||||||
view,
|
view,
|
||||||
|
|
@ -554,16 +542,33 @@ def preview_and_confirm(
|
||||||
interpolation=cv2.INTER_AREA,
|
interpolation=cv2.INTER_AREA,
|
||||||
)
|
)
|
||||||
lines = [
|
lines = [
|
||||||
f"{role.upper()} | {spec.socket} | {spec.sensor}",
|
(
|
||||||
|
f"> {role.upper()} | {spec.socket} | {spec.sensor}"
|
||||||
|
if role == selected_role
|
||||||
|
else f" {role.upper()} | {spec.socket} | {spec.sensor}"
|
||||||
|
),
|
||||||
f"native={spec.width}x{spec.height}",
|
f"native={spec.width}x{spec.height}",
|
||||||
f"packet={latest_type[role]}",
|
f"packet={latest_type[role]}",
|
||||||
|
(
|
||||||
|
f"ROT={orientation[role]['rotate_deg']} "
|
||||||
|
f"FH={int(orientation[role]['flip_horizontal'])} "
|
||||||
|
f"FV={int(orientation[role]['flip_vertical'])}"
|
||||||
|
),
|
||||||
]
|
]
|
||||||
if role == "rgb":
|
if role == "rgb":
|
||||||
lines += [
|
lines += [
|
||||||
f"BAYER={pattern} | DEMOSAIC={algorithm}",
|
f"BAYER={pattern} | PREVIEW=RawProcessorPreview",
|
||||||
"Confira vermelho/azul em objeto conhecido",
|
"Confira vermelho/azul em objeto conhecido",
|
||||||
]
|
]
|
||||||
overlay(panel, lines)
|
overlay(panel, lines)
|
||||||
|
if role == selected_role:
|
||||||
|
cv2.rectangle(
|
||||||
|
panel,
|
||||||
|
(2, 2),
|
||||||
|
(panel_width - 3, panel_height - 3),
|
||||||
|
(0, 255, 255),
|
||||||
|
4,
|
||||||
|
)
|
||||||
panels.append(panel)
|
panels.append(panel)
|
||||||
|
|
||||||
info = np.zeros(
|
info = np.zeros(
|
||||||
|
|
@ -575,14 +580,19 @@ def preview_and_confirm(
|
||||||
"",
|
"",
|
||||||
f"RGB Bayer selecionado: {pattern}",
|
f"RGB Bayer selecionado: {pattern}",
|
||||||
f"RGB processing: linear_demosaic / {algorithm}",
|
f"RGB processing: linear_demosaic / {algorithm}",
|
||||||
|
f"Camera selecionada: {selected_role.upper()}",
|
||||||
"",
|
"",
|
||||||
"B = alternar Bayer",
|
"B = alternar Bayer",
|
||||||
|
"1/2/3 = selecionar RGB/RE/NIR",
|
||||||
|
"R = girar 90 graus horario",
|
||||||
|
"H/V = flip horizontal/vertical",
|
||||||
|
"D = restaurar orientacao padrao",
|
||||||
"S = snapshot",
|
"S = snapshot",
|
||||||
"ENTER = confirmar e promover",
|
"ENTER = confirmar e promover",
|
||||||
"Q/ESC = cancelar",
|
"Q/ESC = cancelar",
|
||||||
"",
|
"",
|
||||||
"Use um objeto vermelho e outro azul",
|
"Confirme cores e o mesmo sentido fisico",
|
||||||
"para confirmar que R/B não estão trocados.",
|
"nas tres cameras antes de pressionar ENTER.",
|
||||||
])
|
])
|
||||||
|
|
||||||
last_board = np.vstack([
|
last_board = np.vstack([
|
||||||
|
|
@ -597,21 +607,48 @@ def preview_and_confirm(
|
||||||
|
|
||||||
key = cv2.waitKey(1) & 0xFF
|
key = cv2.waitKey(1) & 0xFF
|
||||||
if key in (ord("q"), ord("Q"), 27):
|
if key in (ord("q"), ord("Q"), 27):
|
||||||
raise KeyboardInterrupt("Cancelado durante confirmação do Bayer.")
|
raise KeyboardInterrupt("Cancelado durante sanity check físico.")
|
||||||
|
if key == ord("1"):
|
||||||
|
selected_role = "rgb"
|
||||||
|
elif key == ord("2"):
|
||||||
|
selected_role = "re"
|
||||||
|
elif key == ord("3"):
|
||||||
|
selected_role = "nir"
|
||||||
if key in (ord("b"), ord("B")):
|
if key in (ord("b"), ord("B")):
|
||||||
idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS)
|
idx = (BAYER_PATTERNS.index(pattern) + 1) % len(BAYER_PATTERNS)
|
||||||
pattern = BAYER_PATTERNS[idx]
|
pattern = BAYER_PATTERNS[idx]
|
||||||
|
if key in (ord("r"), ord("R")):
|
||||||
|
orientation[selected_role]["rotate_deg"] = (
|
||||||
|
int(orientation[selected_role]["rotate_deg"]) + 90
|
||||||
|
) % 360
|
||||||
|
orientation[selected_role]["source"] = "raw_preview_confirmed"
|
||||||
|
if key in (ord("h"), ord("H")):
|
||||||
|
orientation[selected_role]["flip_horizontal"] = not bool(
|
||||||
|
orientation[selected_role]["flip_horizontal"]
|
||||||
|
)
|
||||||
|
orientation[selected_role]["source"] = "raw_preview_confirmed"
|
||||||
|
if key in (ord("v"), ord("V")):
|
||||||
|
orientation[selected_role]["flip_vertical"] = not bool(
|
||||||
|
orientation[selected_role]["flip_vertical"]
|
||||||
|
)
|
||||||
|
orientation[selected_role]["source"] = "raw_preview_confirmed"
|
||||||
|
if key in (ord("d"), ord("D")):
|
||||||
|
orientation[selected_role] = dict(
|
||||||
|
orientation_defaults[selected_role]
|
||||||
|
)
|
||||||
if key in (ord("s"), ord("S")) and last_board is not None:
|
if key in (ord("s"), ord("S")) and last_board is not None:
|
||||||
ensure_dir(snapshots_dir)
|
ensure_dir(snapshots_dir)
|
||||||
cv2.imwrite(
|
cv2.imwrite(
|
||||||
str(snapshots_dir / f"bayer_{pattern}_{stamp()}.png"),
|
str(snapshots_dir / f"physical_check_{pattern}_{stamp()}.png"),
|
||||||
last_board,
|
last_board,
|
||||||
)
|
)
|
||||||
if key in (10, 13) and last_board is not None:
|
if key in (10, 13) and last_board is not None:
|
||||||
ensure_dir(snapshots_dir)
|
ensure_dir(snapshots_dir)
|
||||||
confirmed_path = snapshots_dir / "bayer_confirmed.png"
|
for role in ROLES:
|
||||||
|
orientation[role]["source"] = "raw_preview_confirmed"
|
||||||
|
confirmed_path = snapshots_dir / "physical_sanity_confirmed.png"
|
||||||
cv2.imwrite(str(confirmed_path), last_board)
|
cv2.imwrite(str(confirmed_path), last_board)
|
||||||
return pattern, confirmed_path
|
return pattern, orientation, confirmed_path
|
||||||
|
|
||||||
time.sleep(0.002)
|
time.sleep(0.002)
|
||||||
finally:
|
finally:
|
||||||
|
|
@ -625,9 +662,9 @@ def artifact_paths(profile_dir: Path) -> dict:
|
||||||
"intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"),
|
"intrinsics_json": norm_path(profile_dir / "intrinsics_calibration_v1.json"),
|
||||||
"flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"),
|
"flatfield_npz": norm_path(profile_dir / "flatfield_maps_v1.npz"),
|
||||||
"flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"),
|
"flatfield_json": norm_path(profile_dir / "flatfield_maps_v1.json"),
|
||||||
"radiometry_json": norm_path(profile_dir / "radiometry_calibration_v1.json"),
|
"radiometry_json": norm_path(profile_dir / "radiometry_calibration_v5.json"),
|
||||||
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v1.json"),
|
"camera_startup_json": norm_path(profile_dir / "camera_startup_profile_v3.json"),
|
||||||
"homography_json": norm_path(profile_dir / "homography_calibration_v1.json"),
|
"homography_json": norm_path(profile_dir / "homography_calibration_v4.json"),
|
||||||
"module_params_json": norm_path(profile_dir / "module_params.json"),
|
"module_params_json": norm_path(profile_dir / "module_params.json"),
|
||||||
"module_params_assembly_report_json": norm_path(
|
"module_params_assembly_report_json": norm_path(
|
||||||
profile_dir / "module_params_assembly_report.json"
|
profile_dir / "module_params_assembly_report.json"
|
||||||
|
|
@ -652,19 +689,27 @@ def build_profile(
|
||||||
profile_name: str,
|
profile_name: str,
|
||||||
profile_dir: Path,
|
profile_dir: Path,
|
||||||
bayer: str,
|
bayer: str,
|
||||||
|
preview_orientation: Dict[str, dict],
|
||||||
confirmed_with_raw_preview: bool,
|
confirmed_with_raw_preview: bool,
|
||||||
confirmation_snapshot: Optional[Path],
|
confirmation_snapshot: Optional[Path],
|
||||||
):
|
):
|
||||||
orientation = {
|
orientation = {
|
||||||
role: {
|
role: normalize_orientation(preview_orientation[role])
|
||||||
"rotate_deg": int(specs[role].preview_rotate_deg),
|
|
||||||
"flip_horizontal": False,
|
|
||||||
"flip_vertical": False,
|
|
||||||
"source": "sensor_default",
|
|
||||||
}
|
|
||||||
for role in ROLES
|
for role in ROLES
|
||||||
}
|
}
|
||||||
|
|
||||||
|
resolved_setup = {}
|
||||||
|
for role in ROLES:
|
||||||
|
item = asdict(specs[role])
|
||||||
|
item["preview_rotate_deg"] = int(orientation[role]["rotate_deg"])
|
||||||
|
item["preview_flip_horizontal"] = bool(
|
||||||
|
orientation[role]["flip_horizontal"]
|
||||||
|
)
|
||||||
|
item["preview_flip_vertical"] = bool(
|
||||||
|
orientation[role]["flip_vertical"]
|
||||||
|
)
|
||||||
|
resolved_setup[role] = item
|
||||||
|
|
||||||
hardware_signature = {
|
hardware_signature = {
|
||||||
role: {
|
role: {
|
||||||
"socket": specs[role].socket,
|
"socket": specs[role].socket,
|
||||||
|
|
@ -686,10 +731,7 @@ def build_profile(
|
||||||
"depthai_version": getattr(dai, "__version__", "unknown"),
|
"depthai_version": getattr(dai, "__version__", "unknown"),
|
||||||
"usb_speed": usb_speed,
|
"usb_speed": usb_speed,
|
||||||
"hardware_signature": hardware_signature,
|
"hardware_signature": hardware_signature,
|
||||||
"resolved_setup": {
|
"resolved_setup": resolved_setup,
|
||||||
role: asdict(specs[role])
|
|
||||||
for role in ROLES
|
|
||||||
},
|
|
||||||
"camera_inventory": rows,
|
"camera_inventory": rows,
|
||||||
"rgb_decode": {
|
"rgb_decode": {
|
||||||
"bayer_pattern": bayer,
|
"bayer_pattern": bayer,
|
||||||
|
|
@ -708,9 +750,23 @@ def build_profile(
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
"preview_orientation_defaults": orientation,
|
"preview_orientation_defaults": orientation,
|
||||||
|
"mounting_sanity_check": {
|
||||||
|
"confirmed_with_live_preview": bool(confirmed_with_raw_preview),
|
||||||
|
"preview_engine": "core.raw_processor_preview.RawProcessorPreview",
|
||||||
|
"orientation_by_role": orientation,
|
||||||
|
"confirmation_snapshot": (
|
||||||
|
norm_path(confirmation_snapshot)
|
||||||
|
if confirmation_snapshot is not None
|
||||||
|
else None
|
||||||
|
),
|
||||||
|
},
|
||||||
"geometry_bootstrap": {
|
"geometry_bootstrap": {
|
||||||
"native_space": "native_stream_no_external_undistort",
|
"native_space": "native_stream_no_external_undistort",
|
||||||
"orientation_status": "preview_default_until_homography",
|
"orientation_status": (
|
||||||
|
"raw_preview_confirmed_until_homography"
|
||||||
|
if confirmed_with_raw_preview
|
||||||
|
else "default_or_cli_headless_until_homography"
|
||||||
|
),
|
||||||
"note": (
|
"note": (
|
||||||
"A Homography homologada será a autoridade final da orientação "
|
"A Homography homologada será a autoridade final da orientação "
|
||||||
"científica e do espaço canônico."
|
"científica e do espaço canônico."
|
||||||
|
|
@ -730,6 +786,15 @@ def build_profile(
|
||||||
"mode": args.rgb_processing_mode,
|
"mode": args.rgb_processing_mode,
|
||||||
"demosaic_algorithm": args.demosaic_algorithm,
|
"demosaic_algorithm": args.demosaic_algorithm,
|
||||||
},
|
},
|
||||||
|
"camera_orientation_bootstrap": {
|
||||||
|
"enabled": True,
|
||||||
|
"input_space": "native_stream_no_external_undistort",
|
||||||
|
"output_space": (
|
||||||
|
"canonical_oriented_stream_no_external_undistort"
|
||||||
|
),
|
||||||
|
"by_role": orientation,
|
||||||
|
"authority": "module_profile_until_homography",
|
||||||
|
},
|
||||||
},
|
},
|
||||||
"artifact_paths": artifact_paths(profile_dir),
|
"artifact_paths": artifact_paths(profile_dir),
|
||||||
"traceability": {
|
"traceability": {
|
||||||
|
|
@ -740,6 +805,13 @@ def build_profile(
|
||||||
},
|
},
|
||||||
"notes": args.notes or "",
|
"notes": args.notes or "",
|
||||||
},
|
},
|
||||||
|
"capture_synchronization": {
|
||||||
|
"hardware_sync_enabled": bool(args.hardware_sync_enabled),
|
||||||
|
"frame_sync_master": args.frame_sync_master,
|
||||||
|
"software_sync_mode": "best",
|
||||||
|
"sync_tolerance_ms": 12.0,
|
||||||
|
"buffer_size": 8,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -789,6 +861,29 @@ def backup_if_exists(path: Path):
|
||||||
return None
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def resolve_initial_orientation(
|
||||||
|
specs: Dict[str, CameraSpec],
|
||||||
|
args,
|
||||||
|
) -> Dict[str, dict]:
|
||||||
|
result = {}
|
||||||
|
for role in ROLES:
|
||||||
|
rotate_override = getattr(args, f"{role}_preview_rotate_deg")
|
||||||
|
flip_h = bool(getattr(args, f"{role}_preview_flip_horizontal"))
|
||||||
|
flip_v = bool(getattr(args, f"{role}_preview_flip_vertical"))
|
||||||
|
has_cli_override = rotate_override is not None or flip_h or flip_v
|
||||||
|
result[role] = normalize_orientation({
|
||||||
|
"rotate_deg": (
|
||||||
|
specs[role].preview_rotate_deg
|
||||||
|
if rotate_override is None
|
||||||
|
else rotate_override
|
||||||
|
),
|
||||||
|
"flip_horizontal": flip_h,
|
||||||
|
"flip_vertical": flip_v,
|
||||||
|
"source": "cli" if has_cli_override else "sensor_default",
|
||||||
|
})
|
||||||
|
return result
|
||||||
|
|
||||||
|
|
||||||
def main():
|
def main():
|
||||||
parser = argparse.ArgumentParser(
|
parser = argparse.ArgumentParser(
|
||||||
description="Bootstrap do profile físico/decode do módulo multiespectral.",
|
description="Bootstrap do profile físico/decode do módulo multiespectral.",
|
||||||
|
|
@ -826,6 +921,25 @@ def main():
|
||||||
parser.add_argument("--fps", type=float, default=10.0)
|
parser.add_argument("--fps", type=float, default=10.0)
|
||||||
parser.add_argument("--panel-width", type=int, default=640)
|
parser.add_argument("--panel-width", type=int, default=640)
|
||||||
parser.add_argument("--panel-height", type=int, default=400)
|
parser.add_argument("--panel-height", type=int, default=400)
|
||||||
|
for role in ROLES:
|
||||||
|
parser.add_argument(
|
||||||
|
f"--{role}-preview-rotate-deg",
|
||||||
|
type=int,
|
||||||
|
default=None,
|
||||||
|
choices=[0, 90, 180, 270],
|
||||||
|
help=(
|
||||||
|
f"Orientação inicial de {role.upper()}; pode ser ajustada "
|
||||||
|
"interativamente no preview."
|
||||||
|
),
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
f"--{role}-preview-flip-horizontal",
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
f"--{role}-preview-flip-vertical",
|
||||||
|
action="store_true",
|
||||||
|
)
|
||||||
parser.add_argument(
|
parser.add_argument(
|
||||||
"--no-preview",
|
"--no-preview",
|
||||||
action="store_true",
|
action="store_true",
|
||||||
|
|
@ -841,6 +955,16 @@ def main():
|
||||||
parser.add_argument("--lens-re", default="")
|
parser.add_argument("--lens-re", default="")
|
||||||
parser.add_argument("--lens-nir", default="")
|
parser.add_argument("--lens-nir", default="")
|
||||||
parser.add_argument("--notes", default="")
|
parser.add_argument("--notes", default="")
|
||||||
|
parser.add_argument(
|
||||||
|
"--hardware-sync-enabled",
|
||||||
|
action=argparse.BooleanOptionalAction,
|
||||||
|
default=False,
|
||||||
|
)
|
||||||
|
parser.add_argument(
|
||||||
|
"--frame-sync-master",
|
||||||
|
default="CAM_A",
|
||||||
|
choices=["CAM_A", "CAM_B", "CAM_C"],
|
||||||
|
)
|
||||||
args = parser.parse_args()
|
args = parser.parse_args()
|
||||||
|
|
||||||
if args.no_preview and not args.yes:
|
if args.no_preview and not args.yes:
|
||||||
|
|
@ -849,6 +973,7 @@ def main():
|
||||||
dev_info = select_device_info(args.mx_id)
|
dev_info = select_device_info(args.mx_id)
|
||||||
rows, actual_mx, usb_speed = discover(dev_info)
|
rows, actual_mx, usb_speed = discover(dev_info)
|
||||||
specs = validate_and_build_specs(rows)
|
specs = validate_and_build_specs(rows)
|
||||||
|
selected_orientation = resolve_initial_orientation(specs, args)
|
||||||
|
|
||||||
rgb_sensor = specs["rgb"].sensor
|
rgb_sensor = specs["rgb"].sensor
|
||||||
default_profile_name = f"mp_{rgb_sensor.lower()}"
|
default_profile_name = f"mp_{rgb_sensor.lower()}"
|
||||||
|
|
@ -898,6 +1023,13 @@ def main():
|
||||||
f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | "
|
f"RGB decode : Bayer={selected_bayer} ({bayer_initial_source}) | "
|
||||||
f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}"
|
f"mode={args.rgb_processing_mode} | algo={args.demosaic_algorithm}"
|
||||||
)
|
)
|
||||||
|
for role in ROLES:
|
||||||
|
orient = selected_orientation[role]
|
||||||
|
print(
|
||||||
|
f"{role.upper():3s} orient. : rotate={orient['rotate_deg']:3d} | "
|
||||||
|
f"flip_h={orient['flip_horizontal']} | "
|
||||||
|
f"flip_v={orient['flip_vertical']} | source={orient['source']}"
|
||||||
|
)
|
||||||
print("=" * 88)
|
print("=" * 88)
|
||||||
|
|
||||||
confirmed_with_raw_preview = False
|
confirmed_with_raw_preview = False
|
||||||
|
|
@ -905,10 +1037,15 @@ def main():
|
||||||
|
|
||||||
try:
|
try:
|
||||||
if not args.no_preview:
|
if not args.no_preview:
|
||||||
selected_bayer, confirmation_snapshot = preview_and_confirm(
|
(
|
||||||
|
selected_bayer,
|
||||||
|
selected_orientation,
|
||||||
|
confirmation_snapshot,
|
||||||
|
) = preview_and_confirm(
|
||||||
dev_info=dev_info,
|
dev_info=dev_info,
|
||||||
specs=specs,
|
specs=specs,
|
||||||
initial_bayer=selected_bayer,
|
initial_bayer=selected_bayer,
|
||||||
|
initial_orientation=selected_orientation,
|
||||||
algorithm=args.demosaic_algorithm,
|
algorithm=args.demosaic_algorithm,
|
||||||
fps=args.fps,
|
fps=args.fps,
|
||||||
panel_width=args.panel_width,
|
panel_width=args.panel_width,
|
||||||
|
|
@ -926,6 +1063,7 @@ def main():
|
||||||
profile_name=profile_name,
|
profile_name=profile_name,
|
||||||
profile_dir=profile_dir,
|
profile_dir=profile_dir,
|
||||||
bayer=selected_bayer,
|
bayer=selected_bayer,
|
||||||
|
preview_orientation=selected_orientation,
|
||||||
confirmed_with_raw_preview=confirmed_with_raw_preview,
|
confirmed_with_raw_preview=confirmed_with_raw_preview,
|
||||||
confirmation_snapshot=confirmation_snapshot,
|
confirmation_snapshot=confirmation_snapshot,
|
||||||
)
|
)
|
||||||
|
|
@ -965,6 +1103,14 @@ def main():
|
||||||
print(f"[SELECTOR] {active_selector}")
|
print(f"[SELECTOR] {active_selector}")
|
||||||
print(f"[SHA256] {selector['profile_sha256']}")
|
print(f"[SHA256] {selector['profile_sha256']}")
|
||||||
print(f"[BAYER] {selected_bayer}")
|
print(f"[BAYER] {selected_bayer}")
|
||||||
|
for role in ROLES:
|
||||||
|
orient = selected_orientation[role]
|
||||||
|
print(
|
||||||
|
f"[ORIENTATION] {role.upper():3s} | "
|
||||||
|
f"rotate={orient['rotate_deg']} | "
|
||||||
|
f"flip_h={orient['flip_horizontal']} | "
|
||||||
|
f"flip_v={orient['flip_vertical']}"
|
||||||
|
)
|
||||||
if backup_profile:
|
if backup_profile:
|
||||||
print(f"[BACKUP PROFILE] {backup_profile}")
|
print(f"[BACKUP PROFILE] {backup_profile}")
|
||||||
if backup_selector:
|
if backup_selector:
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ from __future__ import annotations
|
||||||
import argparse
|
import argparse
|
||||||
import hashlib
|
import hashlib
|
||||||
import json
|
import json
|
||||||
|
import math
|
||||||
import os
|
import os
|
||||||
import shutil
|
import shutil
|
||||||
import zipfile
|
import zipfile
|
||||||
|
|
@ -536,6 +537,78 @@ def resolve_output_path(value: str | Path) -> Path:
|
||||||
return Path(value).expanduser()
|
return Path(value).expanduser()
|
||||||
|
|
||||||
|
|
||||||
|
def normalize_capture_synchronization(
|
||||||
|
value: dict,
|
||||||
|
hardware_signature: dict,
|
||||||
|
) -> dict:
|
||||||
|
"""Valida e normaliza o contrato de sincronismo vindo do Module Profile."""
|
||||||
|
if not isinstance(value, dict):
|
||||||
|
raise RuntimeError(
|
||||||
|
"Module Profile sem capture_synchronization. "
|
||||||
|
"Execute novamente o Script 0 revisado."
|
||||||
|
)
|
||||||
|
|
||||||
|
enabled = value.get("hardware_sync_enabled")
|
||||||
|
if not isinstance(enabled, bool):
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.hardware_sync_enabled deve ser bool."
|
||||||
|
)
|
||||||
|
|
||||||
|
master = str(value.get("frame_sync_master") or "").upper().strip()
|
||||||
|
valid_sockets = {
|
||||||
|
str(item["socket"]).upper()
|
||||||
|
for item in normalize_signature(hardware_signature).values()
|
||||||
|
}
|
||||||
|
if master not in valid_sockets:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.frame_sync_master inválido: "
|
||||||
|
f"{master!r}. Disponíveis={sorted(valid_sockets)}"
|
||||||
|
)
|
||||||
|
|
||||||
|
software_mode = str(
|
||||||
|
value.get("software_sync_mode") or ""
|
||||||
|
).lower().strip()
|
||||||
|
if not software_mode:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.software_sync_mode ausente."
|
||||||
|
)
|
||||||
|
|
||||||
|
try:
|
||||||
|
tolerance_ms = float(value.get("sync_tolerance_ms"))
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.sync_tolerance_ms inválido."
|
||||||
|
) from exc
|
||||||
|
if not math.isfinite(tolerance_ms) or tolerance_ms <= 0.0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.sync_tolerance_ms deve ser finito e > 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
buffer_size = value.get("buffer_size")
|
||||||
|
if isinstance(buffer_size, bool):
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.buffer_size deve ser inteiro positivo."
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
buffer_size = int(buffer_size)
|
||||||
|
except Exception as exc:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.buffer_size inválido."
|
||||||
|
) from exc
|
||||||
|
if buffer_size <= 0:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization.buffer_size deve ser > 0."
|
||||||
|
)
|
||||||
|
|
||||||
|
return {
|
||||||
|
"hardware_sync_enabled": enabled,
|
||||||
|
"frame_sync_master": master,
|
||||||
|
"software_sync_mode": software_mode,
|
||||||
|
"sync_tolerance_ms": tolerance_ms,
|
||||||
|
"buffer_size": buffer_size,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
def validate_module_profile(profile: dict, profile_path: Path):
|
def validate_module_profile(profile: dict, profile_path: Path):
|
||||||
if profile.get("schema") != MODULE_PROFILE_SCHEMA:
|
if profile.get("schema") != MODULE_PROFILE_SCHEMA:
|
||||||
raise RuntimeError(
|
raise RuntimeError(
|
||||||
|
|
@ -602,6 +675,11 @@ def validate_module_profile(profile: dict, profile_path: Path):
|
||||||
if not artifacts.get(key):
|
if not artifacts.get(key):
|
||||||
raise RuntimeError(f"Module Profile sem artifact_paths.{key}.")
|
raise RuntimeError(f"Module Profile sem artifact_paths.{key}.")
|
||||||
|
|
||||||
|
normalize_capture_synchronization(
|
||||||
|
profile.get("capture_synchronization"),
|
||||||
|
signature,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
def load_module_profile_contract(
|
def load_module_profile_contract(
|
||||||
module_profile_arg: Optional[str],
|
module_profile_arg: Optional[str],
|
||||||
|
|
@ -2602,6 +2680,10 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
|
||||||
sensor nem herança de um module_params anterior.
|
sensor nem herança de um module_params anterior.
|
||||||
"""
|
"""
|
||||||
decode = profile["rgb_decode"]
|
decode = profile["rgb_decode"]
|
||||||
|
capture_sync = normalize_capture_synchronization(
|
||||||
|
profile.get("capture_synchronization"),
|
||||||
|
profile.get("hardware_signature"),
|
||||||
|
)
|
||||||
declared = profile.get("runtime_policy")
|
declared = profile.get("runtime_policy")
|
||||||
if declared is None:
|
if declared is None:
|
||||||
declared = {}
|
declared = {}
|
||||||
|
|
@ -2614,6 +2696,7 @@ def runtime_policy_from_module_profile(profile: dict) -> dict:
|
||||||
"capture_mode_requested": "AUTO",
|
"capture_mode_requested": "AUTO",
|
||||||
"capture_mode_effective": "AUTO",
|
"capture_mode_effective": "AUTO",
|
||||||
"raw_policy": "allow_single",
|
"raw_policy": "allow_single",
|
||||||
|
"capture_synchronization": capture_sync,
|
||||||
"rgb_processing": {
|
"rgb_processing": {
|
||||||
"mode": str(decode["mode"]).lower(),
|
"mode": str(decode["mode"]).lower(),
|
||||||
"demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(),
|
"demosaic_algorithm": str(decode["demosaic_algorithm"]).lower(),
|
||||||
|
|
@ -2781,6 +2864,9 @@ def build_final_module_params(
|
||||||
"raw_policy": runtime_policy[
|
"raw_policy": runtime_policy[
|
||||||
"raw_policy"
|
"raw_policy"
|
||||||
],
|
],
|
||||||
|
"capture_synchronization": deepcopy(
|
||||||
|
runtime_policy["capture_synchronization"]
|
||||||
|
),
|
||||||
|
|
||||||
# Compatibilidade root: RGB reference camera
|
# Compatibilidade root: RGB reference camera
|
||||||
"sensor_width": int(
|
"sensor_width": int(
|
||||||
|
|
@ -2958,6 +3044,7 @@ def validate_final_module_params(
|
||||||
"bayer_pattern",
|
"bayer_pattern",
|
||||||
"sensor_size_by_role",
|
"sensor_size_by_role",
|
||||||
"camera_hardware",
|
"camera_hardware",
|
||||||
|
"capture_synchronization",
|
||||||
"camera_orientation",
|
"camera_orientation",
|
||||||
"rgb_processing",
|
"rgb_processing",
|
||||||
"camera_settings",
|
"camera_settings",
|
||||||
|
|
@ -3013,6 +3100,15 @@ def validate_final_module_params(
|
||||||
|
|
||||||
if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS:
|
if str(mp.get("bayer_pattern") or "").upper() not in BAYER_PATTERNS:
|
||||||
raise RuntimeError("bayer_pattern final inválido.")
|
raise RuntimeError("bayer_pattern final inválido.")
|
||||||
|
|
||||||
|
normalized_sync = normalize_capture_synchronization(
|
||||||
|
mp.get("capture_synchronization"),
|
||||||
|
mp.get("camera_hardware"),
|
||||||
|
)
|
||||||
|
if mp.get("capture_synchronization") != normalized_sync:
|
||||||
|
raise RuntimeError(
|
||||||
|
"capture_synchronization final não está normalizado."
|
||||||
|
)
|
||||||
rgb_processing = mp.get("rgb_processing")
|
rgb_processing = mp.get("rgb_processing")
|
||||||
if not isinstance(rgb_processing, dict):
|
if not isinstance(rgb_processing, dict):
|
||||||
raise RuntimeError("rgb_processing final inválido.")
|
raise RuntimeError("rgb_processing final inválido.")
|
||||||
|
|
@ -3698,6 +3794,9 @@ def main():
|
||||||
provenance = {
|
provenance = {
|
||||||
"module_profile_contract": module_profile_provenance(profile_contract),
|
"module_profile_contract": module_profile_provenance(profile_contract),
|
||||||
"configuration_source": "module_profile_only",
|
"configuration_source": "module_profile_only",
|
||||||
|
"capture_synchronization": deepcopy(
|
||||||
|
runtime_policy["capture_synchronization"]
|
||||||
|
),
|
||||||
"hardware_signature": deepcopy(
|
"hardware_signature": deepcopy(
|
||||||
common_signature
|
common_signature
|
||||||
),
|
),
|
||||||
|
|
@ -3832,6 +3931,9 @@ def main():
|
||||||
"camera_orientation": deepcopy(
|
"camera_orientation": deepcopy(
|
||||||
camera_orientation
|
camera_orientation
|
||||||
),
|
),
|
||||||
|
"capture_synchronization": deepcopy(
|
||||||
|
runtime_policy["capture_synchronization"]
|
||||||
|
),
|
||||||
"runtime_policy": runtime_policy,
|
"runtime_policy": runtime_policy,
|
||||||
"calibration_provenance": provenance,
|
"calibration_provenance": provenance,
|
||||||
"final_checks": {
|
"final_checks": {
|
||||||
|
|
@ -3847,6 +3949,7 @@ def main():
|
||||||
"hardware_consistency": "pass",
|
"hardware_consistency": "pass",
|
||||||
"geometry_consistency": "pass",
|
"geometry_consistency": "pass",
|
||||||
"camera_orientation": "pass",
|
"camera_orientation": "pass",
|
||||||
|
"capture_synchronization": "pass",
|
||||||
"orientation_bootstrap_consistency": "pass",
|
"orientation_bootstrap_consistency": "pass",
|
||||||
"startup_radiometry_consistency": "pass",
|
"startup_radiometry_consistency": "pass",
|
||||||
"flatfield_npz_integrity": "pass",
|
"flatfield_npz_integrity": "pass",
|
||||||
|
|
@ -3874,6 +3977,15 @@ def main():
|
||||||
f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}"
|
f"{common_signature['nir']['size'][0]}x{common_signature['nir']['size'][1]}"
|
||||||
)
|
)
|
||||||
print(f"Bayer : {bayer}")
|
print(f"Bayer : {bayer}")
|
||||||
|
capture_sync = runtime_policy["capture_synchronization"]
|
||||||
|
print(
|
||||||
|
"Capture sync: "
|
||||||
|
f"hardware={'ON' if capture_sync['hardware_sync_enabled'] else 'OFF'} | "
|
||||||
|
f"master={capture_sync['frame_sync_master']} | "
|
||||||
|
f"software={capture_sync['software_sync_mode']} | "
|
||||||
|
f"tolerance={capture_sync['sync_tolerance_ms']:.1f}ms | "
|
||||||
|
f"buffer={capture_sync['buffer_size']}"
|
||||||
|
)
|
||||||
print(
|
print(
|
||||||
f"RGB process : "
|
f"RGB process : "
|
||||||
f"{runtime_policy['rgb_processing'].get('mode')}"
|
f"{runtime_policy['rgb_processing'].get('mode')}"
|
||||||
|
|
|
||||||
|
|
@ -41,12 +41,17 @@ class OakFcc3Client:
|
||||||
capture_mode="AUTO",
|
capture_mode="AUTO",
|
||||||
raw_policy="allow_single",
|
raw_policy="allow_single",
|
||||||
module_calibration_json=None,
|
module_calibration_json=None,
|
||||||
sync_mode="best",
|
|
||||||
sync_tolerance_ms=25.0,
|
|
||||||
mx_id=None,
|
mx_id=None,
|
||||||
imu_modo="rotation_vector",
|
imu_modo="rotation_vector",
|
||||||
imu_freq_hz=200,
|
imu_freq_hz=200,
|
||||||
evaluate_quality=True,
|
evaluate_quality=True,
|
||||||
|
|
||||||
|
hardware_sync_enabled=None,
|
||||||
|
frame_sync_master=None,
|
||||||
|
sync_mode=None,
|
||||||
|
sync_tolerance_ms=None,
|
||||||
|
buffer_size=None,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
):
|
):
|
||||||
self.width = width
|
self.width = width
|
||||||
|
|
@ -85,14 +90,18 @@ class OakFcc3Client:
|
||||||
output_dtype=output_dtype,
|
output_dtype=output_dtype,
|
||||||
capture_mode=capture_mode,
|
capture_mode=capture_mode,
|
||||||
raw_policy=raw_policy,
|
raw_policy=raw_policy,
|
||||||
sync_mode=sync_mode,
|
|
||||||
sync_tolerance_ms=sync_tolerance_ms,
|
|
||||||
mx_id=self.mx_id,
|
mx_id=self.mx_id,
|
||||||
module_calibration_json=module_calibration_json,
|
module_calibration_json=module_calibration_json,
|
||||||
|
|
||||||
imu_modo=self.imu_modo,
|
imu_modo=self.imu_modo,
|
||||||
imu_freq_hz=self.imu_freq_hz,
|
imu_freq_hz=self.imu_freq_hz,
|
||||||
|
|
||||||
|
hardware_sync_enabled=hardware_sync_enabled,
|
||||||
|
frame_sync_master=frame_sync_master,
|
||||||
|
sync_mode=sync_mode,
|
||||||
|
sync_tolerance_ms=sync_tolerance_ms,
|
||||||
|
buffer_size=buffer_size,
|
||||||
|
|
||||||
**kwargs,
|
**kwargs,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -44,11 +44,11 @@ class OakFcc3Manager:
|
||||||
capture_mode="AUTO",
|
capture_mode="AUTO",
|
||||||
raw_policy="allow_single",
|
raw_policy="allow_single",
|
||||||
roles=None,
|
roles=None,
|
||||||
sync_mode="best",
|
sync_mode=None,
|
||||||
hardware_sync_enabled=True,
|
hardware_sync_enabled=None,
|
||||||
frame_sync_master="CAM_A",
|
frame_sync_master=None,
|
||||||
sync_tolerance_ms=12.0,
|
sync_tolerance_ms=None,
|
||||||
buffer_size=8,
|
buffer_size=None,
|
||||||
only_camera=None,
|
only_camera=None,
|
||||||
mx_id=None,
|
mx_id=None,
|
||||||
module_calibration_json=None,
|
module_calibration_json=None,
|
||||||
|
|
@ -56,9 +56,6 @@ class OakFcc3Manager:
|
||||||
imu_modo="rotation_vector",
|
imu_modo="rotation_vector",
|
||||||
imu_freq_hz=200,
|
imu_freq_hz=200,
|
||||||
):
|
):
|
||||||
self.hardware_sync_enabled = hardware_sync_enabled
|
|
||||||
self.frame_sync_master = frame_sync_master
|
|
||||||
|
|
||||||
self.fps = fps
|
self.fps = fps
|
||||||
|
|
||||||
# Para compatibilidade, mantemos width/height.
|
# Para compatibilidade, mantemos width/height.
|
||||||
|
|
@ -83,10 +80,6 @@ class OakFcc3Manager:
|
||||||
"CAM_C": "nir",
|
"CAM_C": "nir",
|
||||||
}
|
}
|
||||||
|
|
||||||
self.sync_mode = sync_mode
|
|
||||||
self.sync_tolerance_ms = sync_tolerance_ms
|
|
||||||
self.buffer_size = buffer_size
|
|
||||||
|
|
||||||
self.mx_id = str(mx_id) if mx_id else None
|
self.mx_id = str(mx_id) if mx_id else None
|
||||||
self.dev_info = None
|
self.dev_info = None
|
||||||
self.device = None
|
self.device = None
|
||||||
|
|
@ -127,6 +120,18 @@ class OakFcc3Manager:
|
||||||
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
|
self.fusion_config = (self.module_params or {}).get("fusion_config", {}) or {}
|
||||||
self.aligned_geometry = None
|
self.aligned_geometry = None
|
||||||
|
|
||||||
|
sync_cfg = (self.module_params.get("capture_synchronization", {}) if isinstance(self.module_params, dict) else {})
|
||||||
|
if hardware_sync_enabled is None: hardware_sync_enabled = sync_cfg.get("hardware_sync_enabled", False)
|
||||||
|
if frame_sync_master is None: frame_sync_master = sync_cfg.get("frame_sync_master", "CAM_A")
|
||||||
|
if sync_mode is None: sync_mode = sync_cfg.get("software_sync_mode", "best")
|
||||||
|
if sync_tolerance_ms is None: sync_tolerance_ms = sync_cfg.get("sync_tolerance_ms", 12.0)
|
||||||
|
if buffer_size is None: buffer_size = sync_cfg.get("buffer_size", 8)
|
||||||
|
self.hardware_sync_enabled = bool(hardware_sync_enabled)
|
||||||
|
self.frame_sync_master = str(frame_sync_master)
|
||||||
|
self.sync_mode = str(sync_mode)
|
||||||
|
self.sync_tolerance_ms = float(sync_tolerance_ms)
|
||||||
|
self.buffer_size = int(buffer_size)
|
||||||
|
|
||||||
self.async_capture_enabled = True
|
self.async_capture_enabled = True
|
||||||
self.async_capture_mode = "latest" # latest | queue
|
self.async_capture_mode = "latest" # latest | queue
|
||||||
self.async_capture_max_queue = 2
|
self.async_capture_max_queue = 2
|
||||||
|
|
|
||||||
|
|
@ -1,126 +1,689 @@
|
||||||
|
#!/usr/bin/env python3
|
||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
|
||||||
|
"""
|
||||||
|
RawProcessorPreview
|
||||||
|
===================
|
||||||
|
|
||||||
|
Mini-ISP exclusivamente visual para gerar previews de anotação a partir do
|
||||||
|
RAW Bayer da câmera RGB.
|
||||||
|
|
||||||
|
Contrato:
|
||||||
|
- entrada científica permanece imutável;
|
||||||
|
- saída sempre BGR uint8 para OpenCV;
|
||||||
|
- preserva exatamente HxW do raster RGB;
|
||||||
|
- não aplica resize, crop, rotate/flip, undistort, Flat-Field ou Homography;
|
||||||
|
- não deve ser usado para formar o tensor de treino/inferência.
|
||||||
|
|
||||||
|
Pipeline visual padrão:
|
||||||
|
RAW10/RAW16 -> níveis robustos -> gamma -> demosaico EA/BGR ->
|
||||||
|
WB por regiões claras/neutras -> CLAHE suave em luminância ->
|
||||||
|
saturação leve -> contraste -> unsharp suave.
|
||||||
|
|
||||||
|
As APIs antigas foram preservadas. Chamadas que antes usavam
|
||||||
|
raw16_to_preview_bgr() ou raw16_to_preview_jpg_bytes() continuam válidas.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from copy import deepcopy
|
||||||
|
from typing import Any, Optional
|
||||||
|
|
||||||
import cv2
|
import cv2
|
||||||
import numpy as np
|
import numpy as np
|
||||||
from typing import Optional
|
|
||||||
|
|
||||||
|
|
||||||
class RawProcessorPreview:
|
class RawProcessorPreview:
|
||||||
def __init__(self, sensor_width: int, sensor_height: int, bayer_pattern: str = "GBRG"):
|
VERSION = "raw_processor_preview_v2_2026_09_09"
|
||||||
self.sensor_width = sensor_width
|
CONTRACT_SCHEMA = "rgb_raw_annotation_preview_v2"
|
||||||
self.sensor_height = sensor_height
|
BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
|
||||||
self.bayer_pattern = bayer_pattern.upper()
|
|
||||||
|
|
||||||
def raw16_to_vis8(
|
DEFAULT_CONFIG = {
|
||||||
self, raw16: np.ndarray,
|
"bit_depth": 10,
|
||||||
black_level: Optional[int] = None,
|
"levels": {
|
||||||
white_level: Optional[int] = None,
|
"mode": "robust_percentile",
|
||||||
gamma: float = 2.2,
|
"black_percentile": 0.20,
|
||||||
bit_depth: int = 10
|
"white_percentile": 99.80,
|
||||||
) -> np.ndarray:
|
"sample_max_pixels": 500_000,
|
||||||
"""
|
"minimum_span_codes": 16.0,
|
||||||
Conversão para visualização:
|
},
|
||||||
- auto-level
|
"gamma": 2.20,
|
||||||
- gamma
|
"demosaic": {
|
||||||
"""
|
"algorithm": "edge_aware",
|
||||||
max_val = float((1 << bit_depth) - 1)
|
"output_color_order": "BGR",
|
||||||
|
},
|
||||||
|
"white_balance": {
|
||||||
|
"enabled": True,
|
||||||
|
"method": "neutral_bright_with_gray_world_fallback",
|
||||||
|
"strength": 0.65,
|
||||||
|
"gain_min": 0.60,
|
||||||
|
"gain_max": 1.70,
|
||||||
|
"bright_percentile": 55.0,
|
||||||
|
"neutral_chroma_percentile": 40.0,
|
||||||
|
"min_neutral_pixels": 256,
|
||||||
|
},
|
||||||
|
"clahe": {
|
||||||
|
"enabled": True,
|
||||||
|
"clip_limit": 1.55,
|
||||||
|
"tile_grid_size": 8,
|
||||||
|
},
|
||||||
|
"saturation": {
|
||||||
|
"enabled": True,
|
||||||
|
"factor": 1.04,
|
||||||
|
},
|
||||||
|
"contrast": {
|
||||||
|
"enabled": True,
|
||||||
|
"alpha": 1.04,
|
||||||
|
"beta": 0.0,
|
||||||
|
},
|
||||||
|
"sharpen": {
|
||||||
|
"enabled": True,
|
||||||
|
"sigma": 0.85,
|
||||||
|
"amount": 0.55,
|
||||||
|
"threshold": 2.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
raw = raw16.astype(np.float32)
|
def __init__(
|
||||||
|
self,
|
||||||
|
sensor_width: int,
|
||||||
|
sensor_height: int,
|
||||||
|
bayer_pattern: str = "GBRG",
|
||||||
|
config: Optional[dict] = None,
|
||||||
|
):
|
||||||
|
self.sensor_width = int(sensor_width)
|
||||||
|
self.sensor_height = int(sensor_height)
|
||||||
|
self.bayer_pattern = str(bayer_pattern).upper()
|
||||||
|
self.config = self._deep_merge(self.DEFAULT_CONFIG, config or {})
|
||||||
|
self.last_preview_report: dict[str, Any] = {}
|
||||||
|
self._validate_contract()
|
||||||
|
|
||||||
if black_level is None:
|
@staticmethod
|
||||||
black_level = float(raw.min())
|
def _deep_merge(base: dict, override: dict) -> dict:
|
||||||
if white_level is None:
|
result = deepcopy(base)
|
||||||
white_level = float(raw.max())
|
if not isinstance(override, dict):
|
||||||
|
raise TypeError("config do RawProcessorPreview deve ser dict.")
|
||||||
|
for key, value in override.items():
|
||||||
|
if isinstance(value, dict) and isinstance(result.get(key), dict):
|
||||||
|
result[key] = RawProcessorPreview._deep_merge(result[key], value)
|
||||||
|
else:
|
||||||
|
result[key] = deepcopy(value)
|
||||||
|
return result
|
||||||
|
|
||||||
if white_level <= black_level:
|
def _validate_contract(self):
|
||||||
norm = raw / max_val
|
if self.sensor_width <= 0 or self.sensor_height <= 0:
|
||||||
else:
|
raise ValueError(
|
||||||
norm = (raw - black_level) / (white_level - black_level)
|
f"Dimensão de sensor inválida: {self.sensor_width}x{self.sensor_height}"
|
||||||
|
)
|
||||||
norm = np.clip(norm, 0.0, 1.0)
|
if self.sensor_width % 4 != 0:
|
||||||
|
raise ValueError(
|
||||||
if gamma is not None and gamma > 0:
|
f"RAW10 packed exige largura múltipla de 4: {self.sensor_width}"
|
||||||
norm = np.power(norm, 1.0 / gamma)
|
)
|
||||||
|
if self.bayer_pattern not in self.BAYER_PATTERNS:
|
||||||
return (norm * 255.0).clip(0, 255).astype(np.uint8)
|
|
||||||
|
|
||||||
def _debayer_code(self):
|
|
||||||
mapping = {
|
|
||||||
"RGGB": cv2.COLOR_BayerRG2RGB_EA,
|
|
||||||
"BGGR": cv2.COLOR_BayerBG2RGB_EA,
|
|
||||||
"GRBG": cv2.COLOR_BayerGR2RGB_EA,
|
|
||||||
"GBRG": cv2.COLOR_BayerGB2RGB_EA,
|
|
||||||
}
|
|
||||||
|
|
||||||
if self.bayer_pattern not in mapping:
|
|
||||||
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
|
raise ValueError(f"Padrão Bayer não suportado: {self.bayer_pattern}")
|
||||||
|
|
||||||
return mapping[self.bayer_pattern]
|
bit_depth = int(self.config.get("bit_depth", 10))
|
||||||
|
if bit_depth < 8 or bit_depth > 16:
|
||||||
|
raise ValueError(f"bit_depth inválido: {bit_depth}")
|
||||||
|
|
||||||
def apply_preview_white_balance(self, bgr: np.ndarray, strength: float = 1.0) -> np.ndarray:
|
levels = self.config["levels"]
|
||||||
|
low = float(levels["black_percentile"])
|
||||||
|
high = float(levels["white_percentile"])
|
||||||
|
if not (0.0 <= low < high <= 100.0):
|
||||||
|
raise ValueError(f"Percentis de níveis inválidos: {low}, {high}")
|
||||||
|
|
||||||
|
gamma = float(self.config["gamma"])
|
||||||
|
if not np.isfinite(gamma) or gamma <= 0.0:
|
||||||
|
raise ValueError(f"Gamma inválido: {gamma}")
|
||||||
|
|
||||||
|
wb = self.config["white_balance"]
|
||||||
|
if float(wb["gain_min"]) <= 0.0 or float(wb["gain_max"]) < float(wb["gain_min"]):
|
||||||
|
raise ValueError("Limites de ganho do white balance inválidos.")
|
||||||
|
|
||||||
|
def get_preview_contract(self) -> dict:
|
||||||
|
"""Contrato serializável para registrar no meta do dataset."""
|
||||||
|
return {
|
||||||
|
"schema": self.CONTRACT_SCHEMA,
|
||||||
|
"version": self.VERSION,
|
||||||
|
"purpose": "human_annotation_only",
|
||||||
|
"geometry": "rgb_sensor_native_no_geometric_transform",
|
||||||
|
"sensor_size": [self.sensor_width, self.sensor_height],
|
||||||
|
"bayer_pattern": self.bayer_pattern,
|
||||||
|
"output": {
|
||||||
|
"layout": "HWC",
|
||||||
|
"color_order": "BGR",
|
||||||
|
"dtype": "uint8",
|
||||||
|
"range": [0, 255],
|
||||||
|
},
|
||||||
|
"calibrations_applied": [],
|
||||||
|
"config": deepcopy(self.config),
|
||||||
|
}
|
||||||
|
|
||||||
|
def get_last_preview_report(self) -> dict:
|
||||||
|
return deepcopy(self.last_preview_report)
|
||||||
|
|
||||||
|
# Nome explícito e amigável para integrações que só precisam registrar
|
||||||
|
# a configuração, sem necessariamente gerar um frame antes.
|
||||||
|
def describe_config(self) -> dict:
|
||||||
|
return self.get_preview_contract()
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _finite_float(value, name: str) -> float:
|
||||||
|
result = float(value)
|
||||||
|
if not np.isfinite(result):
|
||||||
|
raise ValueError(f"{name} deve ser finito: {value!r}")
|
||||||
|
return result
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _sample_for_stats(raw: np.ndarray, max_pixels: int) -> np.ndarray:
|
||||||
|
total = int(raw.size)
|
||||||
|
if total <= max_pixels:
|
||||||
|
return raw.reshape(-1)
|
||||||
|
stride = max(1, int(np.ceil(np.sqrt(total / float(max_pixels)))))
|
||||||
|
return raw[::stride, ::stride].reshape(-1)
|
||||||
|
|
||||||
|
def _resolve_levels(
|
||||||
|
self,
|
||||||
|
raw16: np.ndarray,
|
||||||
|
black_level: Optional[float],
|
||||||
|
white_level: Optional[float],
|
||||||
|
bit_depth: int,
|
||||||
|
black_percentile: Optional[float],
|
||||||
|
white_percentile: Optional[float],
|
||||||
|
) -> tuple[float, float, dict]:
|
||||||
|
max_code = float((1 << int(bit_depth)) - 1)
|
||||||
|
levels = self.config["levels"]
|
||||||
|
|
||||||
|
low_pct = float(
|
||||||
|
levels["black_percentile"]
|
||||||
|
if black_percentile is None
|
||||||
|
else black_percentile
|
||||||
|
)
|
||||||
|
high_pct = float(
|
||||||
|
levels["white_percentile"]
|
||||||
|
if white_percentile is None
|
||||||
|
else white_percentile
|
||||||
|
)
|
||||||
|
if not (0.0 <= low_pct < high_pct <= 100.0):
|
||||||
|
raise ValueError(f"Percentis inválidos: {low_pct}, {high_pct}")
|
||||||
|
|
||||||
|
sample = self._sample_for_stats(
|
||||||
|
raw16,
|
||||||
|
max(1, int(levels["sample_max_pixels"])),
|
||||||
|
).astype(np.float32, copy=False)
|
||||||
|
sample = sample[np.isfinite(sample)]
|
||||||
|
if sample.size == 0:
|
||||||
|
raise RuntimeError("RAW não contém pixels finitos para calcular níveis.")
|
||||||
|
|
||||||
|
black_source = "explicit"
|
||||||
|
white_source = "explicit"
|
||||||
|
if black_level is None:
|
||||||
|
black = float(np.percentile(sample, low_pct))
|
||||||
|
black_source = "percentile"
|
||||||
|
else:
|
||||||
|
black = self._finite_float(black_level, "black_level")
|
||||||
|
if white_level is None:
|
||||||
|
white = float(np.percentile(sample, high_pct))
|
||||||
|
white_source = "percentile"
|
||||||
|
else:
|
||||||
|
white = self._finite_float(white_level, "white_level")
|
||||||
|
|
||||||
|
black = float(np.clip(black, 0.0, max_code))
|
||||||
|
white = float(np.clip(white, 0.0, max_code))
|
||||||
|
minimum_span = float(levels["minimum_span_codes"])
|
||||||
|
fallback = False
|
||||||
|
|
||||||
|
if white <= black + minimum_span:
|
||||||
|
black = float(np.clip(np.min(sample), 0.0, max_code))
|
||||||
|
white = float(np.clip(np.max(sample), 0.0, max_code))
|
||||||
|
fallback = True
|
||||||
|
if white <= black:
|
||||||
|
black, white = 0.0, max_code
|
||||||
|
fallback = True
|
||||||
|
|
||||||
|
return black, white, {
|
||||||
|
"black_level": black,
|
||||||
|
"white_level": white,
|
||||||
|
"black_source": black_source,
|
||||||
|
"white_source": white_source,
|
||||||
|
"black_percentile": low_pct,
|
||||||
|
"white_percentile": high_pct,
|
||||||
|
"fallback_to_range": fallback,
|
||||||
|
"sample_pixels": int(sample.size),
|
||||||
|
}
|
||||||
|
|
||||||
|
def raw16_to_vis8(
|
||||||
|
self,
|
||||||
|
raw16: np.ndarray,
|
||||||
|
black_level: Optional[int] = None,
|
||||||
|
white_level: Optional[int] = None,
|
||||||
|
gamma: Optional[float] = None,
|
||||||
|
bit_depth: Optional[int] = None,
|
||||||
|
black_percentile: Optional[float] = None,
|
||||||
|
white_percentile: Optional[float] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Converte mosaico RAW16 para mosaico uint8 de visualização."""
|
||||||
|
raw = np.asarray(raw16)
|
||||||
|
if raw.ndim != 2:
|
||||||
|
raise ValueError(f"RAW16 deve ser 2D; shape={raw.shape}")
|
||||||
|
if raw.shape != (self.sensor_height, self.sensor_width):
|
||||||
|
raise ValueError(
|
||||||
|
f"RAW16 shape={raw.shape}; esperado="
|
||||||
|
f"{(self.sensor_height, self.sensor_width)}"
|
||||||
|
)
|
||||||
|
if not np.issubdtype(raw.dtype, np.integer):
|
||||||
|
raise TypeError(f"RAW16 deve possuir dtype inteiro; recebido={raw.dtype}")
|
||||||
|
|
||||||
|
bit_depth = int(
|
||||||
|
self.config["bit_depth"] if bit_depth is None else bit_depth
|
||||||
|
)
|
||||||
|
gamma_value = self._finite_float(
|
||||||
|
self.config["gamma"] if gamma is None else gamma,
|
||||||
|
"gamma",
|
||||||
|
)
|
||||||
|
if gamma_value <= 0.0:
|
||||||
|
raise ValueError("gamma deve ser > 0.")
|
||||||
|
|
||||||
|
black, white, level_report = self._resolve_levels(
|
||||||
|
raw,
|
||||||
|
black_level,
|
||||||
|
white_level,
|
||||||
|
bit_depth,
|
||||||
|
black_percentile,
|
||||||
|
white_percentile,
|
||||||
|
)
|
||||||
|
norm = (raw.astype(np.float32) - black) / max(white - black, 1.0)
|
||||||
|
np.clip(norm, 0.0, 1.0, out=norm)
|
||||||
|
if gamma_value != 1.0:
|
||||||
|
norm = np.power(norm, 1.0 / gamma_value)
|
||||||
|
|
||||||
|
self.last_preview_report = {
|
||||||
|
"stage": "raw16_to_vis8",
|
||||||
|
"bit_depth": bit_depth,
|
||||||
|
"gamma": gamma_value,
|
||||||
|
"levels": level_report,
|
||||||
|
}
|
||||||
|
return np.clip(norm * 255.0 + 0.5, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
|
def _debayer_code(self):
|
||||||
|
# A API desta classe promete BGR para cv2.imshow/cv2.imwrite.
|
||||||
|
mapping = {
|
||||||
|
"RGGB": ("COLOR_BayerRG2BGR_EA", "COLOR_BayerRG2BGR"),
|
||||||
|
"BGGR": ("COLOR_BayerBG2BGR_EA", "COLOR_BayerBG2BGR"),
|
||||||
|
"GRBG": ("COLOR_BayerGR2BGR_EA", "COLOR_BayerGR2BGR"),
|
||||||
|
"GBRG": ("COLOR_BayerGB2BGR_EA", "COLOR_BayerGB2BGR"),
|
||||||
|
}
|
||||||
|
preferred, fallback = mapping[self.bayer_pattern]
|
||||||
|
return getattr(cv2, preferred, getattr(cv2, fallback))
|
||||||
|
|
||||||
|
def apply_preview_white_balance(
|
||||||
|
self,
|
||||||
|
bgr: np.ndarray,
|
||||||
|
strength: float = 1.0,
|
||||||
|
method: Optional[str] = None,
|
||||||
|
gain_min: Optional[float] = None,
|
||||||
|
gain_max: Optional[float] = None,
|
||||||
|
return_report: bool = False,
|
||||||
|
):
|
||||||
"""
|
"""
|
||||||
Gray-world simples para deixar o preview mais agradável.
|
WB visual robusto para campo agrícola.
|
||||||
Não usar no raw de treino.
|
|
||||||
|
Primeiro procura pixels claros e de baixo croma, reduzindo o risco de
|
||||||
|
o gray-world neutralizar toda a vegetação verde. Se não houver amostra
|
||||||
|
neutra suficiente, usa gray-world com ganhos limitados.
|
||||||
"""
|
"""
|
||||||
img = bgr.astype(np.float32)
|
image = np.asarray(bgr)
|
||||||
|
if image.ndim != 3 or image.shape[2] != 3:
|
||||||
|
raise ValueError(f"WB requer BGR HWC; shape={image.shape}")
|
||||||
|
|
||||||
mean_b = float(img[:, :, 0].mean())
|
cfg = self.config["white_balance"]
|
||||||
mean_g = float(img[:, :, 1].mean())
|
method = str(method or cfg["method"]).lower()
|
||||||
mean_r = float(img[:, :, 2].mean())
|
strength = float(np.clip(self._finite_float(strength, "wb_strength"), 0.0, 1.0))
|
||||||
|
gain_min = float(cfg["gain_min"] if gain_min is None else gain_min)
|
||||||
|
gain_max = float(cfg["gain_max"] if gain_max is None else gain_max)
|
||||||
|
if gain_min <= 0.0 or gain_max < gain_min:
|
||||||
|
raise ValueError("Limites de ganho WB inválidos.")
|
||||||
|
|
||||||
mean_gray = (mean_b + mean_g + mean_r) / 3.0
|
sample = image[::4, ::4].astype(np.float32)
|
||||||
|
flat = sample.reshape(-1, 3)
|
||||||
|
intensity = flat.mean(axis=1)
|
||||||
|
chroma = (flat.max(axis=1) - flat.min(axis=1)) / np.maximum(intensity, 1.0)
|
||||||
|
|
||||||
eps = 1e-6
|
selected = flat
|
||||||
gain_b = mean_gray / max(mean_b, eps)
|
source = "gray_world"
|
||||||
gain_g = mean_gray / max(mean_g, eps)
|
if method.startswith("neutral_bright") and flat.shape[0] > 0:
|
||||||
gain_r = mean_gray / max(mean_r, eps)
|
bright_limit = float(np.percentile(intensity, float(cfg["bright_percentile"])))
|
||||||
|
bright_mask = intensity >= bright_limit
|
||||||
|
bright_chroma = chroma[bright_mask]
|
||||||
|
if bright_chroma.size:
|
||||||
|
neutral_limit = float(
|
||||||
|
np.percentile(
|
||||||
|
bright_chroma,
|
||||||
|
float(cfg["neutral_chroma_percentile"]),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
neutral_mask = bright_mask & (chroma <= neutral_limit)
|
||||||
|
neutral = flat[neutral_mask]
|
||||||
|
if neutral.shape[0] >= int(cfg["min_neutral_pixels"]):
|
||||||
|
selected = neutral
|
||||||
|
source = "neutral_bright"
|
||||||
|
|
||||||
# strength=1 aplica total, strength=0 não aplica
|
means = selected.mean(axis=0)
|
||||||
gain_b = 1.0 + (gain_b - 1.0) * strength
|
target = float(means.mean())
|
||||||
gain_g = 1.0 + (gain_g - 1.0) * strength
|
gains = target / np.maximum(means, 1e-6)
|
||||||
gain_r = 1.0 + (gain_r - 1.0) * strength
|
gains = np.clip(gains, gain_min, gain_max)
|
||||||
|
gains = 1.0 + (gains - 1.0) * strength
|
||||||
|
|
||||||
img[:, :, 0] *= gain_b
|
out = np.clip(
|
||||||
img[:, :, 1] *= gain_g
|
image.astype(np.float32) * gains[None, None, :],
|
||||||
img[:, :, 2] *= gain_r
|
0,
|
||||||
|
255,
|
||||||
|
).astype(np.uint8)
|
||||||
|
report = {
|
||||||
|
"method_requested": method,
|
||||||
|
"source_used": source,
|
||||||
|
"strength": strength,
|
||||||
|
"means_bgr": [float(x) for x in means],
|
||||||
|
"gains_bgr": [float(x) for x in gains],
|
||||||
|
"selected_pixels": int(selected.shape[0]),
|
||||||
|
}
|
||||||
|
return (out, report) if return_report else out
|
||||||
|
|
||||||
return np.clip(img, 0, 255).astype(np.uint8)
|
def apply_preview_contrast(
|
||||||
|
self,
|
||||||
|
bgr: np.ndarray,
|
||||||
|
alpha: float = 1.08,
|
||||||
|
beta: float = 0.0,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""Compatibilidade: contraste/brilho visual sem alterar geometria."""
|
||||||
|
alpha = self._finite_float(alpha, "contrast_alpha")
|
||||||
|
beta = self._finite_float(beta, "contrast_beta")
|
||||||
|
return cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
|
||||||
|
|
||||||
def apply_preview_contrast(self, bgr: np.ndarray, alpha: float = 1.08, beta: float = 0.0) -> np.ndarray:
|
@staticmethod
|
||||||
"""
|
def _apply_clahe_luminance(
|
||||||
Ajuste leve de contraste/brilho para preview.
|
bgr: np.ndarray,
|
||||||
"""
|
clip_limit: float,
|
||||||
out = cv2.convertScaleAbs(bgr, alpha=alpha, beta=beta)
|
tile_grid_size: int,
|
||||||
return out
|
) -> np.ndarray:
|
||||||
|
lab = cv2.cvtColor(bgr, cv2.COLOR_BGR2LAB)
|
||||||
|
luma, a_ch, b_ch = cv2.split(lab)
|
||||||
|
clahe = cv2.createCLAHE(
|
||||||
|
clipLimit=float(clip_limit),
|
||||||
|
tileGridSize=(int(tile_grid_size), int(tile_grid_size)),
|
||||||
|
)
|
||||||
|
return cv2.cvtColor(
|
||||||
|
cv2.merge([clahe.apply(luma), a_ch, b_ch]),
|
||||||
|
cv2.COLOR_LAB2BGR,
|
||||||
|
)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_saturation(bgr: np.ndarray, factor: float) -> np.ndarray:
|
||||||
|
hsv = cv2.cvtColor(bgr, cv2.COLOR_BGR2HSV)
|
||||||
|
sat = hsv[:, :, 1].astype(np.float32) * float(factor)
|
||||||
|
hsv[:, :, 1] = np.clip(sat, 0, 255).astype(np.uint8)
|
||||||
|
return cv2.cvtColor(hsv, cv2.COLOR_HSV2BGR)
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _apply_unsharp(
|
||||||
|
bgr: np.ndarray,
|
||||||
|
sigma: float,
|
||||||
|
amount: float,
|
||||||
|
threshold: float,
|
||||||
|
) -> np.ndarray:
|
||||||
|
image = bgr.astype(np.float32)
|
||||||
|
blurred = cv2.GaussianBlur(
|
||||||
|
image,
|
||||||
|
(0, 0),
|
||||||
|
sigmaX=float(sigma),
|
||||||
|
sigmaY=float(sigma),
|
||||||
|
)
|
||||||
|
sharpened = image + float(amount) * (image - blurred)
|
||||||
|
if threshold > 0.0:
|
||||||
|
mask = np.max(np.abs(image - blurred), axis=2) >= float(threshold)
|
||||||
|
result = image.copy()
|
||||||
|
result[mask] = sharpened[mask]
|
||||||
|
else:
|
||||||
|
result = sharpened
|
||||||
|
return np.clip(result, 0, 255).astype(np.uint8)
|
||||||
|
|
||||||
def raw16_to_preview_bgr(
|
def raw16_to_preview_bgr(
|
||||||
self,
|
self,
|
||||||
raw16: np.ndarray,
|
raw16: np.ndarray,
|
||||||
gamma: float = 2.2,
|
gamma: Optional[float] = None,
|
||||||
wb_strength: float = 0.8,
|
wb_strength: Optional[float] = None,
|
||||||
apply_wb: bool = True,
|
apply_wb: bool = True,
|
||||||
apply_contrast: bool = True,
|
apply_contrast: bool = True,
|
||||||
bit_depth: int = 10,
|
bit_depth: Optional[int] = None,
|
||||||
) -> np.ndarray:
|
*,
|
||||||
"""
|
black_level: Optional[float] = None,
|
||||||
Pipeline de preview bonito:
|
white_level: Optional[float] = None,
|
||||||
1. auto-level + gamma no mosaico
|
black_percentile: Optional[float] = None,
|
||||||
2. demosaic
|
white_percentile: Optional[float] = None,
|
||||||
3. white balance simples
|
apply_clahe: Optional[bool] = None,
|
||||||
4. leve contraste final
|
apply_saturation: Optional[bool] = None,
|
||||||
"""
|
apply_sharpen: Optional[bool] = None,
|
||||||
vis8 = self.raw16_to_vis8(raw16, gamma=gamma, bit_depth=bit_depth)
|
return_report: bool = False,
|
||||||
|
):
|
||||||
|
"""Gera preview BGR bonito preservando exatamente o raster de entrada."""
|
||||||
|
source = np.asarray(raw16)
|
||||||
|
expected_shape = (self.sensor_height, self.sensor_width)
|
||||||
|
if source.shape != expected_shape:
|
||||||
|
raise ValueError(f"RAW16 shape={source.shape}; esperado={expected_shape}")
|
||||||
|
|
||||||
|
effective_gamma = float(
|
||||||
|
self.config["gamma"] if gamma is None else gamma
|
||||||
|
)
|
||||||
|
effective_bit_depth = int(
|
||||||
|
self.config["bit_depth"] if bit_depth is None else bit_depth
|
||||||
|
)
|
||||||
|
effective_wb_strength = float(
|
||||||
|
self.config["white_balance"]["strength"]
|
||||||
|
if wb_strength is None
|
||||||
|
else wb_strength
|
||||||
|
)
|
||||||
|
|
||||||
|
vis8 = self.raw16_to_vis8(
|
||||||
|
source,
|
||||||
|
black_level=black_level,
|
||||||
|
white_level=white_level,
|
||||||
|
gamma=effective_gamma,
|
||||||
|
bit_depth=effective_bit_depth,
|
||||||
|
black_percentile=black_percentile,
|
||||||
|
white_percentile=white_percentile,
|
||||||
|
)
|
||||||
|
level_report = deepcopy(self.last_preview_report.get("levels", {}))
|
||||||
bgr = cv2.cvtColor(vis8, self._debayer_code())
|
bgr = cv2.cvtColor(vis8, self._debayer_code())
|
||||||
|
|
||||||
|
wb_report = {"enabled": False}
|
||||||
if apply_wb:
|
if apply_wb:
|
||||||
bgr = self.apply_preview_white_balance(bgr, strength=wb_strength)
|
bgr, wb_report = self.apply_preview_white_balance(
|
||||||
|
bgr,
|
||||||
|
strength=effective_wb_strength,
|
||||||
|
return_report=True,
|
||||||
|
)
|
||||||
|
wb_report["enabled"] = True
|
||||||
|
|
||||||
|
clahe_cfg = self.config["clahe"]
|
||||||
|
clahe_enabled = bool(clahe_cfg["enabled"] if apply_clahe is None else apply_clahe)
|
||||||
|
if clahe_enabled:
|
||||||
|
bgr = self._apply_clahe_luminance(
|
||||||
|
bgr,
|
||||||
|
float(clahe_cfg["clip_limit"]),
|
||||||
|
int(clahe_cfg["tile_grid_size"]),
|
||||||
|
)
|
||||||
|
|
||||||
|
saturation_cfg = self.config["saturation"]
|
||||||
|
saturation_enabled = bool(
|
||||||
|
saturation_cfg["enabled"]
|
||||||
|
if apply_saturation is None
|
||||||
|
else apply_saturation
|
||||||
|
)
|
||||||
|
if saturation_enabled:
|
||||||
|
bgr = self._apply_saturation(bgr, float(saturation_cfg["factor"]))
|
||||||
|
|
||||||
|
contrast_cfg = self.config["contrast"]
|
||||||
if apply_contrast:
|
if apply_contrast:
|
||||||
bgr = self.apply_preview_contrast(bgr, alpha=1.08, beta=0.0)
|
bgr = self.apply_preview_contrast(
|
||||||
|
bgr,
|
||||||
|
alpha=float(contrast_cfg["alpha"]),
|
||||||
|
beta=float(contrast_cfg["beta"]),
|
||||||
|
)
|
||||||
|
|
||||||
return bgr
|
sharpen_cfg = self.config["sharpen"]
|
||||||
|
sharpen_enabled = bool(
|
||||||
|
sharpen_cfg["enabled"] if apply_sharpen is None else apply_sharpen
|
||||||
|
)
|
||||||
|
if sharpen_enabled:
|
||||||
|
bgr = self._apply_unsharp(
|
||||||
|
bgr,
|
||||||
|
sigma=float(sharpen_cfg["sigma"]),
|
||||||
|
amount=float(sharpen_cfg["amount"]),
|
||||||
|
threshold=float(sharpen_cfg["threshold"]),
|
||||||
|
)
|
||||||
|
|
||||||
def raw16_to_preview_jpg_bytes(self, raw16: np.ndarray, jpeg_quality: int = 95) -> bytes:
|
if bgr.shape != (self.sensor_height, self.sensor_width, 3):
|
||||||
|
raise RuntimeError(
|
||||||
|
f"Preview alterou geometria: {bgr.shape}; esperado="
|
||||||
|
f"{(self.sensor_height, self.sensor_width, 3)}"
|
||||||
|
)
|
||||||
|
if bgr.dtype != np.uint8:
|
||||||
|
raise RuntimeError(f"Preview dtype={bgr.dtype}; esperado=uint8")
|
||||||
|
|
||||||
|
self.last_preview_report = {
|
||||||
|
**self.get_preview_contract(),
|
||||||
|
"effective": {
|
||||||
|
"bit_depth": effective_bit_depth,
|
||||||
|
"gamma": effective_gamma,
|
||||||
|
"levels": level_report,
|
||||||
|
"white_balance": wb_report,
|
||||||
|
"clahe_enabled": clahe_enabled,
|
||||||
|
"saturation_enabled": saturation_enabled,
|
||||||
|
"contrast_enabled": bool(apply_contrast),
|
||||||
|
"sharpen_enabled": sharpen_enabled,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
result = np.ascontiguousarray(bgr)
|
||||||
|
return (result, self.get_last_preview_report()) if return_report else result
|
||||||
|
|
||||||
|
def unpack_raw10_packed(
|
||||||
|
self,
|
||||||
|
packed_frame,
|
||||||
|
sensor_width: Optional[int] = None,
|
||||||
|
sensor_height: Optional[int] = None,
|
||||||
|
stride: Optional[int] = None,
|
||||||
|
) -> np.ndarray:
|
||||||
|
"""
|
||||||
|
Desempacota RAW10 MIPI (4 pixels/5 bytes), aceitando ndarray 1D/2D,
|
||||||
|
bytes e stride/padding por linha.
|
||||||
|
"""
|
||||||
|
width = self.sensor_width if sensor_width is None else int(sensor_width)
|
||||||
|
height = self.sensor_height if sensor_height is None else int(sensor_height)
|
||||||
|
if width != self.sensor_width or height != self.sensor_height:
|
||||||
|
raise ValueError(
|
||||||
|
"RawProcessorPreview preserva o raster configurado; dimensão "
|
||||||
|
f"solicitada={width}x{height}, configurada="
|
||||||
|
f"{self.sensor_width}x{self.sensor_height}."
|
||||||
|
)
|
||||||
|
if width % 4 != 0:
|
||||||
|
raise ValueError(f"RAW10 exige largura múltipla de 4: {width}")
|
||||||
|
|
||||||
|
if isinstance(packed_frame, (bytes, bytearray, memoryview)):
|
||||||
|
packed = np.frombuffer(packed_frame, dtype=np.uint8)
|
||||||
|
else:
|
||||||
|
packed = np.asarray(packed_frame)
|
||||||
|
if packed.ndim == 3 and packed.shape[2] == 1:
|
||||||
|
packed = packed[:, :, 0]
|
||||||
|
|
||||||
|
useful_width = (width // 4) * 5
|
||||||
|
|
||||||
|
if packed.dtype != np.uint8:
|
||||||
|
raise TypeError(
|
||||||
|
f"RAW10 packed deve ser uint8; shape={packed.shape}, dtype={packed.dtype}"
|
||||||
|
)
|
||||||
|
|
||||||
|
if packed.ndim == 1:
|
||||||
|
if stride is None:
|
||||||
|
if packed.size % height != 0:
|
||||||
|
raise ValueError(
|
||||||
|
f"RAW10 1D possui {packed.size} bytes, não divisível por "
|
||||||
|
f"height={height}; informe stride."
|
||||||
|
)
|
||||||
|
stride = packed.size // height
|
||||||
|
stride = int(stride)
|
||||||
|
expected_bytes = stride * height
|
||||||
|
if stride < useful_width or packed.size < expected_bytes:
|
||||||
|
raise ValueError(
|
||||||
|
f"RAW10 1D curto/inválido: bytes={packed.size}, stride={stride}, "
|
||||||
|
f"esperado>={expected_bytes}, payload/linha={useful_width}."
|
||||||
|
)
|
||||||
|
packed = packed[:expected_bytes].reshape(height, stride)
|
||||||
|
elif packed.ndim == 2:
|
||||||
|
if stride is not None and int(stride) != packed.shape[1]:
|
||||||
|
raise ValueError(
|
||||||
|
f"stride={stride} diverge da largura do array={packed.shape[1]}."
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
raise TypeError(
|
||||||
|
f"RAW10 packed deve ser 1D ou 2D; shape={packed.shape}."
|
||||||
|
)
|
||||||
|
|
||||||
|
if packed.shape[0] != height or packed.shape[1] < useful_width:
|
||||||
|
raise ValueError(
|
||||||
|
f"RAW10 packed shape={packed.shape}; esperado >=({height}, {useful_width})"
|
||||||
|
)
|
||||||
|
padding = int(packed.shape[1] - useful_width)
|
||||||
|
if padding > max(4096, useful_width):
|
||||||
|
raise ValueError(
|
||||||
|
f"Padding RAW10 implausível: {padding} bytes/linha; "
|
||||||
|
"verifique width/height/stride."
|
||||||
|
)
|
||||||
|
|
||||||
|
payload = np.ascontiguousarray(packed[:, :useful_width])
|
||||||
|
groups = payload.reshape(height, width // 4, 5)
|
||||||
|
high = groups[:, :, :4].astype(np.uint16)
|
||||||
|
low = groups[:, :, 4].astype(np.uint16)
|
||||||
|
|
||||||
|
out = np.empty((height, width // 4, 4), dtype=np.uint16)
|
||||||
|
out[:, :, 0] = (high[:, :, 0] << 2) | (low & 0x03)
|
||||||
|
out[:, :, 1] = (high[:, :, 1] << 2) | ((low >> 2) & 0x03)
|
||||||
|
out[:, :, 2] = (high[:, :, 2] << 2) | ((low >> 4) & 0x03)
|
||||||
|
out[:, :, 3] = (high[:, :, 3] << 2) | ((low >> 6) & 0x03)
|
||||||
|
return np.ascontiguousarray(out.reshape(height, width))
|
||||||
|
|
||||||
|
def packed_raw10_to_preview_bgr(self, packed_frame: np.ndarray, **kwargs):
|
||||||
|
"""Atalho oficial RAW10 packed -> preview BGR."""
|
||||||
|
raw16 = self.unpack_raw10_packed(packed_frame)
|
||||||
|
return self.raw16_to_preview_bgr(raw16, **kwargs)
|
||||||
|
|
||||||
|
def raw16_to_preview_jpg_bytes(
|
||||||
|
self,
|
||||||
|
raw16: np.ndarray,
|
||||||
|
jpeg_quality: int = 95,
|
||||||
|
) -> bytes:
|
||||||
|
quality = int(np.clip(int(jpeg_quality), 1, 100))
|
||||||
bgr = self.raw16_to_preview_bgr(raw16)
|
bgr = self.raw16_to_preview_bgr(raw16)
|
||||||
ok, enc = cv2.imencode(".jpg", bgr, [int(cv2.IMWRITE_JPEG_QUALITY), int(jpeg_quality)])
|
ok, encoded = cv2.imencode(
|
||||||
|
".jpg",
|
||||||
|
bgr,
|
||||||
|
[int(cv2.IMWRITE_JPEG_QUALITY), quality],
|
||||||
|
)
|
||||||
if not ok:
|
if not ok:
|
||||||
raise RuntimeError("Falha ao codificar preview JPG")
|
raise RuntimeError("Falha ao codificar preview JPG")
|
||||||
return enc.tobytes()
|
return encoded.tobytes()
|
||||||
|
|
||||||
|
def raw16_to_preview_png_bytes(
|
||||||
|
self,
|
||||||
|
raw16: np.ndarray,
|
||||||
|
compression: int = 3,
|
||||||
|
) -> bytes:
|
||||||
|
level = int(np.clip(int(compression), 0, 9))
|
||||||
|
bgr = self.raw16_to_preview_bgr(raw16)
|
||||||
|
ok, encoded = cv2.imencode(
|
||||||
|
".png",
|
||||||
|
bgr,
|
||||||
|
[int(cv2.IMWRITE_PNG_COMPRESSION), level],
|
||||||
|
)
|
||||||
|
if not ok:
|
||||||
|
raise RuntimeError("Falha ao codificar preview PNG")
|
||||||
|
return encoded.tobytes()
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue