ajustado orientacao para ar0234 e calibragem
This commit is contained in:
parent
a212e48c39
commit
255624a087
|
|
@ -613,12 +613,248 @@ def build_reasons(
|
|||
# ============================================================
|
||||
|
||||
|
||||
def load_preview_rgb(sample, chw: np.ndarray, base_module, input_channel_names: Sequence[str]) -> np.ndarray:
|
||||
if getattr(sample, "preview_path", None) is not None and sample.preview_path.is_file():
|
||||
bgr = cv2.imread(str(sample.preview_path), cv2.IMREAD_COLOR)
|
||||
def _preview_stretch_channel(
|
||||
ch: np.ndarray,
|
||||
low_pct: float = 1.0,
|
||||
high_pct: float = 99.5,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Auto-level de um canal APENAS para visualização humana.
|
||||
Não usar para treino/inferência.
|
||||
"""
|
||||
ch = np.asarray(ch, dtype=np.float32)
|
||||
|
||||
finite = np.isfinite(ch)
|
||||
if not finite.any():
|
||||
return np.zeros_like(ch, dtype=np.float32)
|
||||
|
||||
vals = ch[finite]
|
||||
|
||||
lo = float(np.percentile(vals, low_pct))
|
||||
hi = float(np.percentile(vals, high_pct))
|
||||
|
||||
if not np.isfinite(lo):
|
||||
lo = float(vals.min())
|
||||
|
||||
if not np.isfinite(hi):
|
||||
hi = float(vals.max())
|
||||
|
||||
if hi <= lo + 1e-8:
|
||||
lo = float(vals.min())
|
||||
hi = float(vals.max())
|
||||
|
||||
if hi <= lo + 1e-8:
|
||||
return np.zeros_like(ch, dtype=np.float32)
|
||||
|
||||
out = (ch - lo) / (hi - lo)
|
||||
out[~finite] = 0.0
|
||||
|
||||
return np.clip(out, 0.0, 1.0)
|
||||
|
||||
|
||||
def human_preview_rgb(
|
||||
chw: np.ndarray,
|
||||
input_channel_names: Sequence[str],
|
||||
low_pct: float = 1.0,
|
||||
high_pct: float = 99.5,
|
||||
gamma: float = 0.82,
|
||||
clahe_clip: float = 1.6,
|
||||
sharpen: float = 0.15,
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Gera RGB pensado EXCLUSIVAMENTE para revisão humana.
|
||||
|
||||
IMPORTANTE:
|
||||
- não preserva escala radiométrica;
|
||||
- não entra no treino;
|
||||
- não entra na inferência;
|
||||
- não altera tensor/masks;
|
||||
- objetivo é somente maximizar legibilidade visual.
|
||||
"""
|
||||
|
||||
names = [
|
||||
str(x).strip().upper()
|
||||
for x in input_channel_names
|
||||
]
|
||||
|
||||
idx = {
|
||||
name: i
|
||||
for i, name in enumerate(names)
|
||||
}
|
||||
|
||||
missing = [
|
||||
name
|
||||
for name in ("R", "G", "B")
|
||||
if name not in idx
|
||||
]
|
||||
|
||||
if missing:
|
||||
raise RuntimeError(
|
||||
f"Preview RGB impossível: canais ausentes={missing}; "
|
||||
f"disponíveis={names}"
|
||||
)
|
||||
|
||||
rgb = np.stack(
|
||||
[
|
||||
chw[idx["R"]],
|
||||
chw[idx["G"]],
|
||||
chw[idx["B"]],
|
||||
],
|
||||
axis=-1,
|
||||
).astype(np.float32, copy=False)
|
||||
|
||||
rgb = np.nan_to_num(
|
||||
rgb,
|
||||
nan=0.0,
|
||||
posinf=0.0,
|
||||
neginf=0.0,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 1. Auto-level independente por canal.
|
||||
#
|
||||
# Isso deliberadamente abandona a radiometria em favor
|
||||
# de uma imagem agradável e facilmente interpretável.
|
||||
# --------------------------------------------------------
|
||||
finite = np.isfinite(rgb)
|
||||
|
||||
vals = rgb[finite]
|
||||
|
||||
lo = float(np.percentile(vals, low_pct))
|
||||
hi = float(np.percentile(vals, high_pct))
|
||||
|
||||
if hi <= lo + 1e-8:
|
||||
lo = float(vals.min())
|
||||
hi = float(vals.max())
|
||||
|
||||
pretty = (rgb - lo) / max(hi - lo, 1e-8)
|
||||
|
||||
pretty = np.clip(
|
||||
pretty,
|
||||
0.0,
|
||||
1.0,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 2. Gamma para levantar sombras e meios-tons.
|
||||
#
|
||||
# gamma < 1 => clareia.
|
||||
# --------------------------------------------------------
|
||||
gamma = max(float(gamma), 1e-3)
|
||||
|
||||
pretty = np.power(
|
||||
np.clip(pretty, 0.0, 1.0),
|
||||
gamma,
|
||||
)
|
||||
|
||||
u8 = np.clip(
|
||||
np.rint(pretty * 255.0),
|
||||
0,
|
||||
255,
|
||||
).astype(np.uint8)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 3. CLAHE leve somente na luminância.
|
||||
#
|
||||
# Ajuda muito a enxergar folhas/contornos em chão escuro
|
||||
# sem estourar tanto as cores.
|
||||
# --------------------------------------------------------
|
||||
if clahe_clip > 0:
|
||||
lab = cv2.cvtColor(
|
||||
u8,
|
||||
cv2.COLOR_RGB2LAB,
|
||||
)
|
||||
|
||||
clahe = cv2.createCLAHE(
|
||||
clipLimit=float(clahe_clip),
|
||||
tileGridSize=(8, 8),
|
||||
)
|
||||
|
||||
lab[..., 0] = clahe.apply(lab[..., 0])
|
||||
|
||||
u8 = cv2.cvtColor(
|
||||
lab,
|
||||
cv2.COLOR_LAB2RGB,
|
||||
)
|
||||
|
||||
# --------------------------------------------------------
|
||||
# 4. Nitidez leve.
|
||||
#
|
||||
# Só para ajudar o olho humano em bordas/folhas.
|
||||
# --------------------------------------------------------
|
||||
amount = max(0.0, float(sharpen))
|
||||
|
||||
if amount > 0:
|
||||
blur = cv2.GaussianBlur(
|
||||
u8,
|
||||
(0, 0),
|
||||
0.8,
|
||||
)
|
||||
|
||||
u8 = cv2.addWeighted(
|
||||
u8,
|
||||
1.0 + amount,
|
||||
blur,
|
||||
-amount,
|
||||
0.0,
|
||||
)
|
||||
|
||||
return u8
|
||||
|
||||
|
||||
def load_preview_rgb(
|
||||
sample,
|
||||
chw: np.ndarray,
|
||||
base_module,
|
||||
input_channel_names: Sequence[str],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Preview para REVISÃO HUMANA.
|
||||
|
||||
Prioridade agora é sempre reconstruir RGB a partir do tensor
|
||||
radiométrico com tratamento visual.
|
||||
|
||||
O preview original do dataset é usado apenas como fallback.
|
||||
"""
|
||||
|
||||
try:
|
||||
return human_preview_rgb(
|
||||
chw,
|
||||
input_channel_names,
|
||||
low_pct=1.0,
|
||||
high_pct=99.5,
|
||||
gamma=0.82,
|
||||
clahe_clip=1.6,
|
||||
sharpen=0.15,
|
||||
)
|
||||
|
||||
except Exception as exc:
|
||||
print(
|
||||
f"[PREVIEW][WARN] Falha no preview humano "
|
||||
f"{getattr(sample, 'base', '?')}: {exc}"
|
||||
)
|
||||
|
||||
# Fallback 1: preview já existente.
|
||||
if (
|
||||
getattr(sample, "preview_path", None) is not None
|
||||
and sample.preview_path.is_file()
|
||||
):
|
||||
bgr = cv2.imread(
|
||||
str(sample.preview_path),
|
||||
cv2.IMREAD_COLOR,
|
||||
)
|
||||
|
||||
if bgr is not None:
|
||||
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB)
|
||||
return base_module.tensor_to_preview_rgb(chw, input_channel_names)
|
||||
return cv2.cvtColor(
|
||||
bgr,
|
||||
cv2.COLOR_BGR2RGB,
|
||||
)
|
||||
|
||||
# Fallback 2: método legado.
|
||||
return base_module.tensor_to_preview_rgb(
|
||||
chw,
|
||||
input_channel_names,
|
||||
)
|
||||
|
||||
|
||||
def save_review_item(
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
|
|
@ -177,6 +177,26 @@ MONO_SENSOR_MODES = {
|
|||
}
|
||||
}
|
||||
|
||||
SENSOR_ORIENTATION = {
|
||||
"OV9782": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
|
||||
"AR0234": {
|
||||
"rotate_deg": 180,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
|
||||
"OV9282": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
}
|
||||
|
||||
SCHEMA = "multispec_focus_qc_v3"
|
||||
|
||||
|
||||
|
|
@ -494,6 +514,88 @@ def rect_from_norm(shape_hw, x0, y0, x1, y1):
|
|||
)
|
||||
|
||||
|
||||
def apply_sensor_orientation(
|
||||
img: np.ndarray,
|
||||
sensor_name: str,
|
||||
) -> np.ndarray:
|
||||
if img is None:
|
||||
return img
|
||||
|
||||
sensor = str(
|
||||
sensor_name
|
||||
or ""
|
||||
).strip().upper()
|
||||
|
||||
cfg = (
|
||||
SENSOR_ORIENTATION.get(
|
||||
sensor,
|
||||
{}
|
||||
)
|
||||
or {}
|
||||
)
|
||||
|
||||
rotate_deg = int(
|
||||
cfg.get(
|
||||
"rotate_deg",
|
||||
0,
|
||||
)
|
||||
) % 360
|
||||
|
||||
flip_horizontal = bool(
|
||||
cfg.get(
|
||||
"flip_horizontal",
|
||||
False,
|
||||
)
|
||||
)
|
||||
|
||||
flip_vertical = bool(
|
||||
cfg.get(
|
||||
"flip_vertical",
|
||||
False,
|
||||
)
|
||||
)
|
||||
|
||||
if rotate_deg == 90:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_90_CLOCKWISE,
|
||||
)
|
||||
|
||||
elif rotate_deg == 180:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_180,
|
||||
)
|
||||
|
||||
elif rotate_deg == 270:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_90_COUNTERCLOCKWISE,
|
||||
)
|
||||
|
||||
elif rotate_deg != 0:
|
||||
raise RuntimeError(
|
||||
f"rotate_deg inválido para "
|
||||
f"{sensor}: {rotate_deg}"
|
||||
)
|
||||
|
||||
if flip_horizontal:
|
||||
img = cv2.flip(
|
||||
img,
|
||||
1,
|
||||
)
|
||||
|
||||
if flip_vertical:
|
||||
img = cv2.flip(
|
||||
img,
|
||||
0,
|
||||
)
|
||||
|
||||
return np.ascontiguousarray(
|
||||
img
|
||||
)
|
||||
|
||||
|
||||
# ============================================================
|
||||
# Descoberta e contrato de hardware
|
||||
# ============================================================
|
||||
|
|
@ -1673,6 +1775,16 @@ class FocusRuntime:
|
|||
if img is None:
|
||||
continue
|
||||
|
||||
# ----------------------------------------------------
|
||||
# Orientação canônica para QC / visualização
|
||||
# ----------------------------------------------------
|
||||
spec = self.specs[role]
|
||||
|
||||
img = apply_sensor_orientation(
|
||||
img,
|
||||
sensor_name=spec.sensor_name,
|
||||
)
|
||||
|
||||
if img.ndim == 2:
|
||||
img = cv2.cvtColor(
|
||||
img,
|
||||
|
|
@ -2695,6 +2807,20 @@ def build_base_report(
|
|||
"results_by_role": {},
|
||||
"snapshots": [],
|
||||
"promoted": False,
|
||||
"orientation_used": {
|
||||
role: {
|
||||
"sensor": specs[role].sensor_name,
|
||||
**SENSOR_ORIENTATION.get(
|
||||
specs[role].sensor_name,
|
||||
{
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
),
|
||||
}
|
||||
for role in ROLES
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -26,6 +26,10 @@ IMPORTANTE:
|
|||
Se no futuro ativarmos undistort no runtime, a homografia precisa ser
|
||||
recalibrada no mesmo espaço geométrico undistorted.
|
||||
|
||||
A orientação configurável deste calibrador é SOMENTE de preview.
|
||||
ChArUco, pose, K, D, reprojection e acceptance permanecem sempre no
|
||||
espaço nativo do sensor.
|
||||
|
||||
Fluxo:
|
||||
1) Mostre o ChArUco nas 3 câmeras.
|
||||
2) ENTER trava EXP/ISO.
|
||||
|
|
@ -80,6 +84,26 @@ MONO_MODES = {
|
|||
"OV9282": ("THE_800_P", 1280, 800),
|
||||
}
|
||||
|
||||
# Orientação SOMENTE para visualização/diagnóstico.
|
||||
# Não altera os frames científicos usados para calcular Intrinsics.
|
||||
PREVIEW_ORIENTATION_BY_SENSOR = {
|
||||
"OV9782": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"AR0234": {
|
||||
"rotate_deg": 180,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"OV9282": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
}
|
||||
|
||||
QA = {
|
||||
"min_markers": 4,
|
||||
"min_corners": 10,
|
||||
|
|
@ -183,6 +207,105 @@ def safe_int(v, default=None):
|
|||
return default
|
||||
|
||||
|
||||
def normalize_preview_orientation(cfg):
|
||||
cfg = cfg or {}
|
||||
|
||||
rotate_deg = int(cfg.get("rotate_deg", 0)) % 360
|
||||
|
||||
if rotate_deg not in (0, 90, 180, 270):
|
||||
raise RuntimeError(
|
||||
f"Preview rotate_deg inválido: {rotate_deg}. "
|
||||
"Permitidos=0,90,180,270."
|
||||
)
|
||||
|
||||
flip_horizontal = cfg.get("flip_horizontal", False)
|
||||
flip_vertical = cfg.get("flip_vertical", False)
|
||||
|
||||
if not isinstance(flip_horizontal, bool):
|
||||
raise RuntimeError("flip_horizontal deve ser bool.")
|
||||
|
||||
if not isinstance(flip_vertical, bool):
|
||||
raise RuntimeError("flip_vertical deve ser bool.")
|
||||
|
||||
return {
|
||||
"rotate_deg": rotate_deg,
|
||||
"flip_horizontal": flip_horizontal,
|
||||
"flip_vertical": flip_vertical,
|
||||
}
|
||||
|
||||
|
||||
def apply_preview_orientation(img: np.ndarray, cfg: dict) -> np.ndarray:
|
||||
"""
|
||||
Ajusta SOMENTE apresentação/diagnóstico.
|
||||
|
||||
NÃO use a saída desta função para:
|
||||
- detect_charuco()
|
||||
- pose_signature()
|
||||
- calibrateCamera()
|
||||
- cálculo de K/D
|
||||
- reprojection error
|
||||
|
||||
Intrinsics permanecem no espaço nativo do sensor.
|
||||
"""
|
||||
if img is None:
|
||||
return img
|
||||
|
||||
cfg = normalize_preview_orientation(cfg)
|
||||
rotate_deg = cfg["rotate_deg"]
|
||||
|
||||
if rotate_deg == 90:
|
||||
img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
||||
elif rotate_deg == 180:
|
||||
img = cv2.rotate(img, cv2.ROTATE_180)
|
||||
elif rotate_deg == 270:
|
||||
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||
|
||||
if cfg["flip_horizontal"]:
|
||||
img = cv2.flip(img, 1)
|
||||
|
||||
if cfg["flip_vertical"]:
|
||||
img = cv2.flip(img, 0)
|
||||
|
||||
return np.ascontiguousarray(img)
|
||||
|
||||
|
||||
def resolve_preview_orientation(specs, args):
|
||||
"""
|
||||
Resolve orientação visual por sensor, com override opcional por role.
|
||||
|
||||
Isso NÃO muda o domínio científico da calibração.
|
||||
"""
|
||||
result = {}
|
||||
|
||||
for role in ROLES:
|
||||
sensor = str(specs[role].sensor).upper()
|
||||
|
||||
base = dict(
|
||||
PREVIEW_ORIENTATION_BY_SENSOR.get(
|
||||
sensor,
|
||||
{
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
rotate_override = getattr(args, f"{role}_preview_rotate_deg")
|
||||
if rotate_override is not None:
|
||||
base["rotate_deg"] = int(rotate_override)
|
||||
|
||||
if getattr(args, f"{role}_preview_flip_horizontal"):
|
||||
base["flip_horizontal"] = True
|
||||
|
||||
if getattr(args, f"{role}_preview_flip_vertical"):
|
||||
base["flip_vertical"] = True
|
||||
|
||||
result[role] = normalize_preview_orientation(base)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def overlay(img, lines, x=12, y=24, scale=0.45, step=19):
|
||||
yy = y
|
||||
for line in lines:
|
||||
|
|
@ -1402,6 +1525,9 @@ def module_fragment(evaluation, specs):
|
|||
"undistort_products": m["undistort_products"],
|
||||
}
|
||||
|
||||
# IMPORTANTE:
|
||||
# camera_orientation NÃO entra neste fragmento. K/D pertencem ao raster
|
||||
# nativo do sensor. A orientação canônica é um estágio geométrico separado.
|
||||
return {
|
||||
"intrinsics_config": {
|
||||
"enabled": True,
|
||||
|
|
@ -1440,20 +1566,64 @@ def draw_points(panel, points, src_shape, color):
|
|||
cv2.FONT_HERSHEY_SIMPLEX, 0.3, color, 1, cv2.LINE_AA)
|
||||
|
||||
|
||||
def save_view_preview(path, frames, det):
|
||||
def save_view_preview(path, frames, det, preview_orientation):
|
||||
"""
|
||||
Salva PNG de diagnóstico em orientação visual canônica.
|
||||
|
||||
Os corners são desenhados primeiro no espaço nativo e só depois imagem +
|
||||
overlays são rotacionados juntos. Os dados científicos permanecem nativos.
|
||||
"""
|
||||
panels = []
|
||||
colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)}
|
||||
colors = {
|
||||
"rgb": (0, 255, 0),
|
||||
"re": (0, 255, 255),
|
||||
"nir": (255, 255, 0),
|
||||
}
|
||||
|
||||
for role in ROLES:
|
||||
img = frames[role]
|
||||
bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
p = cv2.resize(bgr, (480, 300), interpolation=cv2.INTER_AREA)
|
||||
draw_points(p, det["detections"][role]["points"], img.shape[:2], colors[role])
|
||||
overlay(p, [
|
||||
img_native = frames[role]
|
||||
|
||||
bgr = (
|
||||
img_native.copy()
|
||||
if img_native.ndim == 3
|
||||
else cv2.cvtColor(img_native, cv2.COLOR_GRAY2BGR)
|
||||
)
|
||||
|
||||
# Points/detections ainda estão no espaço nativo.
|
||||
draw_points(
|
||||
bgr,
|
||||
det["detections"][role]["points"],
|
||||
img_native.shape[:2],
|
||||
colors[role],
|
||||
)
|
||||
|
||||
# Somente agora converte para a orientação visual do módulo.
|
||||
bgr = apply_preview_orientation(
|
||||
bgr,
|
||||
preview_orientation[role],
|
||||
)
|
||||
|
||||
p = cv2.resize(
|
||||
bgr,
|
||||
(480, 300),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
orient = preview_orientation[role]
|
||||
overlay(
|
||||
p,
|
||||
[
|
||||
role.upper(),
|
||||
f"markers={det['detections'][role]['markers']}",
|
||||
f"corners={det['detections'][role]['corners']}",
|
||||
], x=8, y=18, scale=0.36, step=16)
|
||||
f"DISPLAY ROT={orient['rotate_deg']} | CALIB=NATIVE",
|
||||
],
|
||||
x=8,
|
||||
y=18,
|
||||
scale=0.34,
|
||||
step=15,
|
||||
)
|
||||
|
||||
panels.append(p)
|
||||
|
||||
cv2.imwrite(str(path), np.hstack(panels))
|
||||
|
|
@ -1468,47 +1638,119 @@ def undistorted(frame, cam_result, alpha):
|
|||
return cv2.undistort(frame, K, D, None, newK)
|
||||
|
||||
|
||||
def build_ui(runtime, specs, views, last_det, evaluation,
|
||||
selected_role, show_undist, args, msg=""):
|
||||
def build_ui(
|
||||
runtime,
|
||||
specs,
|
||||
views,
|
||||
last_det,
|
||||
evaluation,
|
||||
selected_role,
|
||||
show_undist,
|
||||
args,
|
||||
preview_orientation,
|
||||
msg="",
|
||||
):
|
||||
"""
|
||||
UI em orientação canônica SOMENTE para visualização.
|
||||
|
||||
runtime.frames permanece SENSOR-NATIVE do começo ao fim.
|
||||
"""
|
||||
pw, ph = args.panel_width, args.panel_height
|
||||
panels = {}
|
||||
colors = {"rgb": (0, 255, 0), "re": (0, 255, 255), "nir": (255, 255, 0)}
|
||||
colors = {
|
||||
"rgb": (0, 255, 0),
|
||||
"re": (0, 255, 255),
|
||||
"nir": (255, 255, 0),
|
||||
}
|
||||
|
||||
for role in ROLES:
|
||||
frame = runtime.frames[role]
|
||||
frame_native = runtime.frames[role]
|
||||
|
||||
if show_undist and evaluation is not None and role == selected_role:
|
||||
frame = undistorted(
|
||||
frame, evaluation["cameras"][role], args.preview_alpha
|
||||
if frame_native is None:
|
||||
raise RuntimeError(f"{role}: frame ausente ao montar UI.")
|
||||
|
||||
showing_undist = bool(
|
||||
show_undist
|
||||
and evaluation is not None
|
||||
and role == selected_role
|
||||
)
|
||||
|
||||
bgr = frame if frame.ndim == 3 else cv2.cvtColor(frame, cv2.COLOR_GRAY2BGR)
|
||||
p = cv2.resize(bgr, (pw, ph), interpolation=cv2.INTER_AREA)
|
||||
if showing_undist:
|
||||
frame_view = undistorted(
|
||||
frame_native,
|
||||
evaluation["cameras"][role],
|
||||
args.preview_alpha,
|
||||
)
|
||||
else:
|
||||
frame_view = frame_native
|
||||
|
||||
if last_det is not None:
|
||||
bgr = (
|
||||
frame_view.copy()
|
||||
if frame_view.ndim == 3
|
||||
else cv2.cvtColor(frame_view, cv2.COLOR_GRAY2BGR)
|
||||
)
|
||||
|
||||
# last_det pertence ao frame distorcido/nativo.
|
||||
# Desenhamos antes da rotação visual. No preview undistorted os corners
|
||||
# antigos seriam geometricamente incorretos, então não os desenhamos.
|
||||
if last_det is not None and not showing_undist:
|
||||
draw_points(
|
||||
p,
|
||||
bgr,
|
||||
last_det["detections"][role]["points"],
|
||||
runtime.frames[role].shape[:2],
|
||||
frame_native.shape[:2],
|
||||
colors[role],
|
||||
)
|
||||
|
||||
overlay(p, [
|
||||
bgr = apply_preview_orientation(
|
||||
bgr,
|
||||
preview_orientation[role],
|
||||
)
|
||||
|
||||
p = cv2.resize(
|
||||
bgr,
|
||||
(pw, ph),
|
||||
interpolation=cv2.INTER_AREA,
|
||||
)
|
||||
|
||||
orient = preview_orientation[role]
|
||||
mode = (
|
||||
"UNDIST PREVIEW | CALIB=NATIVE"
|
||||
if showing_undist
|
||||
else "DISPLAY CANONICAL | CALIB=NATIVE"
|
||||
)
|
||||
|
||||
overlay(
|
||||
p,
|
||||
[
|
||||
f"{role.upper()} | {specs[role].sensor}",
|
||||
f"{specs[role].width}x{specs[role].height}",
|
||||
"UNDIST PREVIEW" if (show_undist and role == selected_role) else "NATIVE",
|
||||
f"native={specs[role].width}x{specs[role].height}",
|
||||
mode,
|
||||
(
|
||||
f"ROT={orient['rotate_deg']} "
|
||||
f"FH={int(orient['flip_horizontal'])} "
|
||||
f"FV={int(orient['flip_vertical'])}"
|
||||
),
|
||||
(
|
||||
f"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
|
||||
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}",
|
||||
], x=8, y=18, scale=0.36, step=16)
|
||||
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}"
|
||||
),
|
||||
],
|
||||
x=8,
|
||||
y=18,
|
||||
scale=0.34,
|
||||
step=15,
|
||||
)
|
||||
|
||||
panels[role] = p
|
||||
|
||||
data = np.zeros((ph, pw, 3), dtype=np.uint8)
|
||||
selected_orient = preview_orientation[selected_role]
|
||||
|
||||
lines = [
|
||||
"INTRINSICS CALIBRATION - PRODUCTION",
|
||||
f"views={len(views)} / min={QA['min_views']} rec={QA['recommended_views']}",
|
||||
f"preview={selected_role.upper()} undist={'ON' if show_undist else 'OFF'}",
|
||||
f"measurement=NATIVE | display_rot={selected_orient['rotate_deg']}",
|
||||
"",
|
||||
"G capture | A calibrate",
|
||||
"V clear | S snapshot",
|
||||
|
|
@ -1539,8 +1781,14 @@ def build_ui(runtime, specs, views, last_det, evaluation,
|
|||
|
||||
if msg:
|
||||
cv2.putText(
|
||||
board, msg, (16, board.shape[0] - 14),
|
||||
cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 0), 2, cv2.LINE_AA,
|
||||
board,
|
||||
msg,
|
||||
(16, board.shape[0] - 14),
|
||||
cv2.FONT_HERSHEY_SIMPLEX,
|
||||
0.52,
|
||||
(0, 255, 0),
|
||||
2,
|
||||
cv2.LINE_AA,
|
||||
)
|
||||
|
||||
return board
|
||||
|
|
@ -1596,6 +1844,30 @@ def main():
|
|||
ap.add_argument("--lens-nir", default="")
|
||||
ap.add_argument("--notes", default="")
|
||||
|
||||
# Orientação SOMENTE de preview.
|
||||
# Defaults vêm do sensor detectado; as flags abaixo são overrides locais.
|
||||
for role in ROLES:
|
||||
ap.add_argument(
|
||||
f"--{role}-preview-rotate-deg",
|
||||
type=int,
|
||||
default=None,
|
||||
choices=[0, 90, 180, 270],
|
||||
help=(
|
||||
"Rotação SOMENTE do preview. "
|
||||
"Não altera o domínio da calibração intrínseca."
|
||||
),
|
||||
)
|
||||
ap.add_argument(
|
||||
f"--{role}-preview-flip-horizontal",
|
||||
action="store_true",
|
||||
help="Flip horizontal somente para preview.",
|
||||
)
|
||||
ap.add_argument(
|
||||
f"--{role}-preview-flip-vertical",
|
||||
action="store_true",
|
||||
help="Flip vertical somente para preview.",
|
||||
)
|
||||
|
||||
args = ap.parse_args()
|
||||
|
||||
sid = stamp()
|
||||
|
|
@ -1629,6 +1901,7 @@ def main():
|
|||
try:
|
||||
rows, actual_mx, usb = discover(args.mx_id)
|
||||
specs = validate_specs(rows)
|
||||
preview_orientation = resolve_preview_orientation(specs, args)
|
||||
|
||||
pipeline, isp = build_pipeline(
|
||||
specs,
|
||||
|
|
@ -1650,6 +1923,18 @@ def main():
|
|||
f"{s.width}x{s.height} | {s.source}"
|
||||
)
|
||||
|
||||
print("-" * 82)
|
||||
print("[GEOMETRY] Intrinsics measurement = SENSOR NATIVE")
|
||||
for role in ROLES:
|
||||
o = preview_orientation[role]
|
||||
print(
|
||||
f"[PREVIEW] {role.upper():3s} | "
|
||||
f"rotate={o['rotate_deg']:3d} | "
|
||||
f"flip_h={o['flip_horizontal']} | "
|
||||
f"flip_v={o['flip_vertical']} | "
|
||||
"runtime_effect=NONE"
|
||||
)
|
||||
|
||||
print("=" * 82)
|
||||
|
||||
report = {
|
||||
|
|
@ -1664,6 +1949,24 @@ def main():
|
|||
"usb_speed": usb,
|
||||
"hardware_signature": hardware_signature(specs),
|
||||
"calibration_space": CALIBRATION_SPACE,
|
||||
"geometry_contract": {
|
||||
"intrinsics_measurement_space": CALIBRATION_SPACE,
|
||||
"measurement_orientation": "sensor_native",
|
||||
"preview_orientation_only": True,
|
||||
"preview_orientation_by_role": {
|
||||
role: {
|
||||
"sensor": specs[role].sensor,
|
||||
**preview_orientation[role],
|
||||
}
|
||||
for role in ROLES
|
||||
},
|
||||
"runtime_orientation_effect": "none",
|
||||
"notes": (
|
||||
"Preview pode ser rotacionado/flipado para o operador, "
|
||||
"mas ChArUco, pose, K, D, reprojection e acceptance "
|
||||
"permanecem no espaço nativo do sensor."
|
||||
),
|
||||
},
|
||||
"charuco": {
|
||||
"dictionary": args.charuco_dictionary,
|
||||
"squares_x": args.charuco_squares_x,
|
||||
|
|
@ -1715,8 +2018,20 @@ def main():
|
|||
panels = []
|
||||
|
||||
for role in ROLES:
|
||||
img = runtime.frames[role]
|
||||
bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR)
|
||||
img_native = runtime.frames[role]
|
||||
bgr = (
|
||||
img_native.copy()
|
||||
if img_native.ndim == 3
|
||||
else cv2.cvtColor(img_native, cv2.COLOR_GRAY2BGR)
|
||||
)
|
||||
|
||||
# Somente visual. detect_triplet acima continua usando
|
||||
# runtime.frames no espaço nativo.
|
||||
bgr = apply_preview_orientation(
|
||||
bgr,
|
||||
preview_orientation[role],
|
||||
)
|
||||
|
||||
p = cv2.resize(
|
||||
bgr,
|
||||
(args.panel_width, args.panel_height),
|
||||
|
|
@ -1724,12 +2039,14 @@ def main():
|
|||
)
|
||||
|
||||
d = det["detections"][role]
|
||||
orient = preview_orientation[role]
|
||||
overlay(p, [
|
||||
f"{role.upper()} | {specs[role].sensor}",
|
||||
f"markers={d['markers']} corners={d['corners']}",
|
||||
f"DISPLAY ROT={orient['rotate_deg']} | CALIB=NATIVE",
|
||||
f"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
|
||||
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}",
|
||||
], x=8, y=18, scale=0.38, step=17)
|
||||
], x=8, y=18, scale=0.36, step=16)
|
||||
|
||||
panels.append(p)
|
||||
|
||||
|
|
@ -1794,7 +2111,7 @@ def main():
|
|||
|
||||
ui = build_ui(
|
||||
runtime, specs, views, last_det, evaluation,
|
||||
selected_role, show_undist, args, msg
|
||||
selected_role, show_undist, args, preview_orientation, msg
|
||||
)
|
||||
|
||||
cv2.imshow(window, ui)
|
||||
|
|
@ -1870,6 +2187,12 @@ def main():
|
|||
"captured_at": now_str(),
|
||||
"pose": pose,
|
||||
"controls": trip["controls"],
|
||||
"measurement_space": CALIBRATION_SPACE,
|
||||
"measurement_orientation": "sensor_native",
|
||||
"preview_orientation_by_role": {
|
||||
role: dict(preview_orientation[role])
|
||||
for role in ROLES
|
||||
},
|
||||
"detections": {
|
||||
role: {
|
||||
"markers": det["detections"][role]["markers"],
|
||||
|
|
@ -1885,7 +2208,7 @@ def main():
|
|||
|
||||
preview = views_dir / f"view_{view_id:03d}.png"
|
||||
save_view_preview(
|
||||
preview, trip["frames"], det
|
||||
preview, trip["frames"], det, preview_orientation
|
||||
)
|
||||
view["preview_png"] = str(preview)
|
||||
|
||||
|
|
@ -1991,8 +2314,13 @@ def main():
|
|||
|
||||
print("[RUNTIME] runtime_undistort permanece false.")
|
||||
print(
|
||||
"[IMPORTANTE] Se ativarmos undistort, recalibrar Homography "
|
||||
"no espaço undistorted."
|
||||
"[GEOMETRY] K/D permanecem no SENSOR NATIVE. "
|
||||
"Preview orientation não altera a calibração."
|
||||
)
|
||||
print(
|
||||
"[IMPORTANTE] Homography deve consumir a orientação "
|
||||
"canônica antes da detecção/cálculo. Se ativarmos "
|
||||
"undistort, recalibrar Homography no novo espaço."
|
||||
)
|
||||
print(f"[ACTIVE] {active}")
|
||||
print("=" * 82)
|
||||
|
|
|
|||
|
|
@ -28,6 +28,8 @@ Princípios de produto:
|
|||
12. Só promove para "active" se o acceptance test passar.
|
||||
13. Preserva calibração ativa anterior em caso de falha.
|
||||
14. Gera metadados, hashes, previews e sugestão de flatfield_config.
|
||||
15. Orientação de câmera é SOMENTE visual neste calibrador; o Flat-Field
|
||||
científico permanece no espaço nativo/decode do sensor.
|
||||
|
||||
Dependências:
|
||||
-------------
|
||||
|
|
@ -161,6 +163,26 @@ SUPPORTED_MONO = {
|
|||
|
||||
BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG")
|
||||
|
||||
# Orientação SOMENTE de preview/diagnóstico.
|
||||
# O Flat-Field científico permanece no espaço nativo/decode do sensor.
|
||||
PREVIEW_ORIENTATION_BY_SENSOR = {
|
||||
"OV9782": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"AR0234": {
|
||||
"rotate_deg": 180,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"OV9282": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
}
|
||||
|
||||
# Raw10
|
||||
RAW10_MAX = 1023.0
|
||||
RAW10_WHITE_SAT = 1018
|
||||
|
|
@ -402,6 +424,142 @@ def resize_panel(img: np.ndarray, width: int, height: int) -> np.ndarray:
|
|||
return cv2.resize(img, (int(width), int(height)), interpolation=cv2.INTER_AREA)
|
||||
|
||||
|
||||
def normalize_preview_orientation(cfg: Optional[dict]) -> dict:
|
||||
"""Normaliza orientação usada EXCLUSIVAMENTE em UI/previews."""
|
||||
cfg = cfg or {}
|
||||
|
||||
try:
|
||||
rotate_deg = int(cfg.get("rotate_deg", 0)) % 360
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"preview rotate_deg inválido: {cfg.get('rotate_deg')!r}"
|
||||
) from exc
|
||||
|
||||
if rotate_deg not in (0, 90, 180, 270):
|
||||
raise RuntimeError(
|
||||
f"preview rotate_deg inválido: {rotate_deg}. "
|
||||
"Permitidos=0,90,180,270."
|
||||
)
|
||||
|
||||
flip_horizontal = cfg.get("flip_horizontal", False)
|
||||
flip_vertical = cfg.get("flip_vertical", False)
|
||||
|
||||
if not isinstance(flip_horizontal, bool):
|
||||
raise RuntimeError("preview flip_horizontal deve ser bool.")
|
||||
if not isinstance(flip_vertical, bool):
|
||||
raise RuntimeError("preview flip_vertical deve ser bool.")
|
||||
|
||||
return {
|
||||
"rotate_deg": rotate_deg,
|
||||
"flip_horizontal": flip_horizontal,
|
||||
"flip_vertical": flip_vertical,
|
||||
"source": str(cfg.get("source") or "unknown"),
|
||||
}
|
||||
|
||||
|
||||
def apply_preview_orientation(
|
||||
img: np.ndarray,
|
||||
cfg: Optional[dict],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Aplica orientação SOMENTE para apresentação/diagnóstico.
|
||||
|
||||
Nunca use a saída para build_gain_maps(), acceptance_test(),
|
||||
save_candidate_npz() ou qualquer etapa científica do Flat-Field.
|
||||
"""
|
||||
if img is None:
|
||||
return img
|
||||
|
||||
cfg = normalize_preview_orientation(cfg)
|
||||
rotate_deg = cfg["rotate_deg"]
|
||||
|
||||
if rotate_deg == 90:
|
||||
img = cv2.rotate(img, cv2.ROTATE_90_CLOCKWISE)
|
||||
elif rotate_deg == 180:
|
||||
img = cv2.rotate(img, cv2.ROTATE_180)
|
||||
elif rotate_deg == 270:
|
||||
img = cv2.rotate(img, cv2.ROTATE_90_COUNTERCLOCKWISE)
|
||||
|
||||
if cfg["flip_horizontal"]:
|
||||
img = cv2.flip(img, 1)
|
||||
if cfg["flip_vertical"]:
|
||||
img = cv2.flip(img, 0)
|
||||
|
||||
return np.ascontiguousarray(img)
|
||||
|
||||
|
||||
def resolve_preview_orientation(
|
||||
specs: Dict[str, "CameraSpec"],
|
||||
args,
|
||||
module_params: Optional[dict] = None,
|
||||
) -> Dict[str, dict]:
|
||||
"""
|
||||
Precedência:
|
||||
1. defaults por sensor;
|
||||
2. camera_orientation do module_params, se enabled=true;
|
||||
3. overrides explícitos de CLI.
|
||||
|
||||
O resultado afeta SOMENTE UI/previews.
|
||||
"""
|
||||
module_params = module_params or {}
|
||||
mp_orientation = module_params.get("camera_orientation", {})
|
||||
mp_orientation_enabled = bool(
|
||||
isinstance(mp_orientation, dict)
|
||||
and mp_orientation.get("enabled", False)
|
||||
)
|
||||
mp_by_role = (
|
||||
mp_orientation.get("by_role", {})
|
||||
if mp_orientation_enabled
|
||||
else {}
|
||||
)
|
||||
|
||||
result = {}
|
||||
|
||||
for role in ROLES:
|
||||
sensor = str(specs[role].sensor_name or "").upper()
|
||||
|
||||
base = dict(
|
||||
PREVIEW_ORIENTATION_BY_SENSOR.get(
|
||||
sensor,
|
||||
{
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
base["source"] = "sensor_default"
|
||||
|
||||
mp_role = mp_by_role.get(role) if isinstance(mp_by_role, dict) else None
|
||||
if isinstance(mp_role, dict):
|
||||
for key in ("rotate_deg", "flip_horizontal", "flip_vertical"):
|
||||
if key in mp_role:
|
||||
base[key] = mp_role[key]
|
||||
base["source"] = "module_params"
|
||||
|
||||
rotate_override = getattr(args, f"{role}_preview_rotate_deg", None)
|
||||
cli_override = False
|
||||
|
||||
if rotate_override is not None:
|
||||
base["rotate_deg"] = int(rotate_override)
|
||||
cli_override = True
|
||||
|
||||
if bool(getattr(args, f"{role}_preview_flip_horizontal", False)):
|
||||
base["flip_horizontal"] = True
|
||||
cli_override = True
|
||||
|
||||
if bool(getattr(args, f"{role}_preview_flip_vertical", False)):
|
||||
base["flip_vertical"] = True
|
||||
cli_override = True
|
||||
|
||||
if cli_override:
|
||||
base["source"] = "cli_override"
|
||||
|
||||
result[role] = normalize_preview_orientation(base)
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def status_rank(status: str) -> int:
|
||||
return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2)
|
||||
|
||||
|
|
@ -1212,6 +1370,7 @@ class AcquisitionContext:
|
|||
panel_width: int,
|
||||
panel_height: int,
|
||||
rgb_bayer: str,
|
||||
preview_orientation: Dict[str, dict],
|
||||
):
|
||||
self.device = device
|
||||
self.specs = specs
|
||||
|
|
@ -1219,6 +1378,12 @@ class AcquisitionContext:
|
|||
self.panel_width = int(panel_width)
|
||||
self.panel_height = int(panel_height)
|
||||
self.rgb_bayer = rgb_bayer
|
||||
self.preview_orientation = {
|
||||
role: normalize_preview_orientation(
|
||||
(preview_orientation or {}).get(role, {})
|
||||
)
|
||||
for role in ROLES
|
||||
}
|
||||
|
||||
self.raw_queues = {
|
||||
role: device.getOutputQueue(
|
||||
|
|
@ -1449,6 +1614,12 @@ class AcquisitionContext:
|
|||
u8 = raw10_display(raw)
|
||||
view = cv2.cvtColor(u8, cv2.COLOR_GRAY2BGR)
|
||||
|
||||
# SOMENTE DISPLAY. latest_raw permanece sensor-native.
|
||||
view = apply_preview_orientation(
|
||||
view,
|
||||
self.preview_orientation[role],
|
||||
)
|
||||
|
||||
panel = resize_panel(view, pw, ph)
|
||||
|
||||
st = raw_stats(raw)
|
||||
|
|
@ -1458,7 +1629,12 @@ class AcquisitionContext:
|
|||
panel,
|
||||
[
|
||||
f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}",
|
||||
f"RAW10 {spec.width}x{spec.height}",
|
||||
f"RAW10 native {spec.width}x{spec.height}",
|
||||
(
|
||||
f"DISPLAY ROT={self.preview_orientation[role]['rotate_deg']} "
|
||||
f"FH={int(self.preview_orientation[role]['flip_horizontal'])} "
|
||||
f"FV={int(self.preview_orientation[role]['flip_vertical'])}"
|
||||
),
|
||||
f"p50={st['p50']:.3f} p99={st['p99']:.3f}",
|
||||
f"sat={st['sat_pct']:.3f}% dark={st['dark_pct']:.3f}%",
|
||||
f"EXP={ctrl.get('exposure_time_us')}us ISO={ctrl.get('sensitivity_iso')}",
|
||||
|
|
@ -1855,6 +2031,25 @@ def disable_nonflat_processing(core: RawProcessorCore):
|
|||
except Exception:
|
||||
pass
|
||||
|
||||
# Belt-and-suspenders: Flat científico deve nascer ANTES da orientação.
|
||||
try:
|
||||
orientation = getattr(core, "camera_orientation_config", None)
|
||||
if isinstance(orientation, dict):
|
||||
orientation["enabled"] = False
|
||||
orientation["input_space"] = "native_stream_no_external_undistort"
|
||||
orientation["output_space"] = "native_stream_no_external_undistort"
|
||||
|
||||
by_role = orientation.get("by_role")
|
||||
if isinstance(by_role, dict):
|
||||
for role in ROLES:
|
||||
item = by_role.get(role)
|
||||
if isinstance(item, dict):
|
||||
item["rotate_deg"] = 0
|
||||
item["flip_horizontal"] = False
|
||||
item["flip_vertical"] = False
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
|
||||
def create_processing_core(
|
||||
specs: Dict[str, CameraSpec],
|
||||
|
|
@ -1958,6 +2153,12 @@ def decode_reference(
|
|||
specs: Dict[str, CameraSpec],
|
||||
raw_means: Dict[str, np.ndarray],
|
||||
) -> dict:
|
||||
"""
|
||||
Decode científico do Flat-Field no espaço NATIVO/DECODE.
|
||||
|
||||
Não aplica camera_orientation, Homography, crop ou resize de fusão.
|
||||
Isso é intencional: no runtime o Flat nativo vem antes do ROTATE/FLIP.
|
||||
"""
|
||||
frame = {}
|
||||
|
||||
for role in ROLES:
|
||||
|
|
@ -2307,8 +2508,29 @@ def save_previews(
|
|||
white_signal_a: dict,
|
||||
white_b: dict,
|
||||
dark: Optional[dict],
|
||||
preview_orientation: Optional[Dict[str, dict]] = None,
|
||||
):
|
||||
"""
|
||||
Salva previews em dois domínios:
|
||||
|
||||
nomes legados + *_native.png
|
||||
espaço científico real do Flat-Field;
|
||||
|
||||
*_display.png
|
||||
cópia rotacionada/flipada SOMENTE para inspeção humana.
|
||||
|
||||
O NPZ e o acceptance usam exclusivamente os arrays nativos.
|
||||
"""
|
||||
ensure_dir(out_dir)
|
||||
preview_orientation = preview_orientation or {}
|
||||
|
||||
channel_to_role = {
|
||||
"R": "rgb",
|
||||
"G": "rgb",
|
||||
"B": "rgb",
|
||||
"RE": "re",
|
||||
"NIR": "nir",
|
||||
}
|
||||
|
||||
for ch in CHANNELS:
|
||||
gain = gain_maps[ch]
|
||||
|
|
@ -2318,26 +2540,60 @@ def save_previews(
|
|||
validation_signal = np.maximum(wb - d, 1e-8)
|
||||
corrected = validation_signal * gain
|
||||
|
||||
cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gray_bgr(gain))
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), gray_bgr(white_signal_a[ch]))
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), gray_bgr(validation_signal))
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), gray_bgr(corrected))
|
||||
gain_native = gray_bgr(gain)
|
||||
white_a_native = gray_bgr(white_signal_a[ch])
|
||||
before_native = gray_bgr(validation_signal)
|
||||
after_native = gray_bgr(corrected)
|
||||
|
||||
# Comparativo lado a lado.
|
||||
before = gray_bgr(validation_signal)
|
||||
after = gray_bgr(corrected)
|
||||
# Nomes históricos continuam apontando para o espaço científico nativo.
|
||||
cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gain_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), white_a_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), before_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), after_native)
|
||||
|
||||
h = min(before.shape[0], after.shape[0])
|
||||
w = min(before.shape[1], after.shape[1])
|
||||
cv2.imwrite(str(out_dir / f"{ch}_gain_native.png"), gain_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_A_signal_native.png"), white_a_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_before_native.png"), before_native)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_after_native.png"), after_native)
|
||||
|
||||
before = cv2.resize(before, (w, h))
|
||||
after = cv2.resize(after, (w, h))
|
||||
before_cmp = before_native.copy()
|
||||
after_cmp = after_native.copy()
|
||||
overlay_hud(before_cmp, [f"{ch} BEFORE | NATIVE"], x=12, y=26, font_scale=0.65, line_step=24)
|
||||
overlay_hud(after_cmp, [f"{ch} AFTER | NATIVE"], x=12, y=26, font_scale=0.65, line_step=24)
|
||||
native_side = np.hstack([before_cmp, after_cmp])
|
||||
|
||||
overlay_hud(before, [f"{ch} BEFORE"], x=12, y=26, font_scale=0.65, line_step=24)
|
||||
overlay_hud(after, [f"{ch} AFTER"], x=12, y=26, font_scale=0.65, line_step=24)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), native_side)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_validation_compare_native.png"), native_side)
|
||||
|
||||
side = np.hstack([before, after])
|
||||
cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), side)
|
||||
# DISPLAY CANÔNICO, sem efeito científico.
|
||||
role = channel_to_role[ch]
|
||||
orient_cfg = preview_orientation.get(role, {})
|
||||
orient = normalize_preview_orientation(orient_cfg)
|
||||
|
||||
gain_display = apply_preview_orientation(gain_native.copy(), orient_cfg)
|
||||
white_a_display = apply_preview_orientation(white_a_native.copy(), orient_cfg)
|
||||
before_display = apply_preview_orientation(before_native.copy(), orient_cfg)
|
||||
after_display = apply_preview_orientation(after_native.copy(), orient_cfg)
|
||||
|
||||
cv2.imwrite(str(out_dir / f"{ch}_gain_display.png"), gain_display)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_A_signal_display.png"), white_a_display)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_before_display.png"), before_display)
|
||||
cv2.imwrite(str(out_dir / f"{ch}_white_B_after_display.png"), after_display)
|
||||
|
||||
before_disp_cmp = before_display.copy()
|
||||
after_disp_cmp = after_display.copy()
|
||||
overlay_hud(
|
||||
before_disp_cmp,
|
||||
[f"{ch} BEFORE | DISPLAY", f"ROT={orient['rotate_deg']}"],
|
||||
x=12, y=26, font_scale=0.55, line_step=22,
|
||||
)
|
||||
overlay_hud(
|
||||
after_disp_cmp,
|
||||
[f"{ch} AFTER | DISPLAY", f"ROT={orient['rotate_deg']}"],
|
||||
x=12, y=26, font_scale=0.55, line_step=22,
|
||||
)
|
||||
display_side = np.hstack([before_disp_cmp, after_disp_cmp])
|
||||
cv2.imwrite(str(out_dir / f"{ch}_validation_compare_display.png"), display_side)
|
||||
|
||||
|
||||
# ============================================================
|
||||
|
|
@ -2486,6 +2742,29 @@ def main():
|
|||
parser.add_argument(f"--{role}-exp-us", type=int, default=None)
|
||||
parser.add_argument(f"--{role}-iso", type=int, default=None)
|
||||
|
||||
# Orientação SOMENTE de preview/diagnóstico.
|
||||
for role in ROLES:
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-rotate-deg",
|
||||
type=int,
|
||||
default=None,
|
||||
choices=[0, 90, 180, 270],
|
||||
help=(
|
||||
"Rotação SOMENTE da UI/previews. "
|
||||
"Flat científico permanece no espaço nativo."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-flip-horizontal",
|
||||
action="store_true",
|
||||
help="Flip horizontal SOMENTE da UI/previews.",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-flip-vertical",
|
||||
action="store_true",
|
||||
help="Flip vertical SOMENTE da UI/previews.",
|
||||
)
|
||||
|
||||
# Processamento
|
||||
parser.add_argument(
|
||||
"--sigma-frac",
|
||||
|
|
@ -2578,6 +2857,12 @@ def main():
|
|||
|
||||
rgb_spec = specs["rgb"]
|
||||
|
||||
preview_orientation = resolve_preview_orientation(
|
||||
specs,
|
||||
args,
|
||||
module_params=module_params,
|
||||
)
|
||||
|
||||
print("=" * 82)
|
||||
print("FLAT-FIELD CALIBRATION - PRODUCTION")
|
||||
print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}")
|
||||
|
|
@ -2593,6 +2878,18 @@ def main():
|
|||
f"{s.width}x{s.height} | {s.resolution_enum}{extra}"
|
||||
)
|
||||
|
||||
print("-" * 82)
|
||||
print("[GEOMETRY] Flat measurement = SENSOR/DECODE NATIVE")
|
||||
for role in ROLES:
|
||||
o = preview_orientation[role]
|
||||
print(
|
||||
f"[PREVIEW] {role.upper():3s} | "
|
||||
f"rotate={o['rotate_deg']:3d} | "
|
||||
f"flip_h={o['flip_horizontal']} | "
|
||||
f"flip_v={o['flip_vertical']} | "
|
||||
f"source={o['source']} | scientific_effect=NONE"
|
||||
)
|
||||
|
||||
print("=" * 82)
|
||||
|
||||
# --------------------------------------------------------
|
||||
|
|
@ -2628,6 +2925,24 @@ def main():
|
|||
role: asdict(spec)
|
||||
for role, spec in specs.items()
|
||||
},
|
||||
"geometry_contract": {
|
||||
"flat_measurement_space": "native_camera_space",
|
||||
"decode_space": "sensor_native_decode",
|
||||
"measurement_orientation": "sensor_native",
|
||||
"preview_orientation_only": True,
|
||||
"preview_orientation_by_role": {
|
||||
role: {
|
||||
"sensor": specs[role].sensor_name,
|
||||
**preview_orientation[role],
|
||||
}
|
||||
for role in ROLES
|
||||
},
|
||||
"runtime_order": (
|
||||
"decode -> radiometric_normalization -> flat_native -> "
|
||||
"camera_orientation -> homography"
|
||||
),
|
||||
"scientific_orientation_effect": "none",
|
||||
},
|
||||
"module_params_path": args.module_params,
|
||||
"rgb_processing": None,
|
||||
"capture": {
|
||||
|
|
@ -2667,6 +2982,7 @@ def main():
|
|||
panel_width=args.panel_width,
|
||||
panel_height=args.panel_height,
|
||||
rgb_bayer=rgb_spec.bayer_pattern,
|
||||
preview_orientation=preview_orientation,
|
||||
)
|
||||
|
||||
# ----------------------------------------------------
|
||||
|
|
@ -2681,6 +2997,7 @@ def main():
|
|||
"enhancement_forced_off": True,
|
||||
"flatfield_forced_off": True,
|
||||
"radiometric_normalization_forced_off": True,
|
||||
"camera_orientation_forced_off_for_measurement": True,
|
||||
}
|
||||
|
||||
print(
|
||||
|
|
@ -2922,6 +3239,7 @@ def main():
|
|||
white_signal_a=white_signal_a,
|
||||
white_b=white_b,
|
||||
dark=dark,
|
||||
preview_orientation=preview_orientation,
|
||||
)
|
||||
|
||||
report["candidate_sha256"] = sha256_file(candidate_npz)
|
||||
|
|
@ -3015,6 +3333,11 @@ def main():
|
|||
f"RGB: {rgb_spec.sensor_name} {rgb_spec.width}x{rgb_spec.height}",
|
||||
f"Bayer: {rgb_spec.bayer_pattern}",
|
||||
f"RGB processing: {report['rgb_processing']['mode']}",
|
||||
"Flat scientific space: NATIVE",
|
||||
(
|
||||
f"RGB display rotate: "
|
||||
f"{preview_orientation['rgb']['rotate_deg']} deg"
|
||||
),
|
||||
"",
|
||||
]
|
||||
|
||||
|
|
|
|||
|
|
@ -67,11 +67,17 @@ Domínio de medição
|
|||
A medição é feita no RAW nativo da câmera, antes de:
|
||||
- demosaic;
|
||||
- enhancement;
|
||||
- camera orientation;
|
||||
- homografia;
|
||||
- flat gain;
|
||||
- resize;
|
||||
- patch normalization.
|
||||
|
||||
A orientação de câmera deste calibrador é SOMENTE VISUAL:
|
||||
- pode rotacionar/flipar a UI e imagens de evidência;
|
||||
- NÃO altera ROI, sweep, regressão, p50, reference_controls ou acceptance;
|
||||
- o artefato radiométrico continua pertencendo ao espaço RAW nativo.
|
||||
|
||||
Isso casa com o propósito do bloco runtime:
|
||||
apply_stage = after_dark_before_flat_gain
|
||||
|
||||
|
|
@ -210,6 +216,26 @@ MONO_SENSOR_MODES = {
|
|||
},
|
||||
}
|
||||
|
||||
# Orientação EXCLUSIVAMENTE de preview/evidência.
|
||||
# O domínio científico permanece native_sensor_raw_linear.
|
||||
PREVIEW_ORIENTATION_BY_SENSOR = {
|
||||
"OV9782": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"AR0234": {
|
||||
"rotate_deg": 180,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
"OV9282": {
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
}
|
||||
|
||||
SCHEMA = "multispec_radiometric_calibration_v5"
|
||||
CALIBRATION_DOMAIN = "native_sensor_raw_linear"
|
||||
NORMALIZATION_METHOD = "oak_ae_frame_controls_v1"
|
||||
|
|
@ -518,6 +544,201 @@ def resize_panel(
|
|||
)
|
||||
|
||||
|
||||
def normalize_preview_orientation(cfg: Optional[dict]) -> dict:
|
||||
"""Normaliza orientação usada SOMENTE para UI/evidência visual."""
|
||||
cfg = cfg or {}
|
||||
|
||||
try:
|
||||
rotate_deg = int(cfg.get("rotate_deg", 0)) % 360
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"preview rotate_deg inválido: {cfg.get('rotate_deg')!r}"
|
||||
) from exc
|
||||
|
||||
if rotate_deg not in (0, 90, 180, 270):
|
||||
raise RuntimeError(
|
||||
f"preview rotate_deg inválido: {rotate_deg}. "
|
||||
"Permitidos=0,90,180,270."
|
||||
)
|
||||
|
||||
flip_horizontal = cfg.get("flip_horizontal", False)
|
||||
flip_vertical = cfg.get("flip_vertical", False)
|
||||
|
||||
if not isinstance(flip_horizontal, bool):
|
||||
raise RuntimeError("preview flip_horizontal deve ser bool.")
|
||||
|
||||
if not isinstance(flip_vertical, bool):
|
||||
raise RuntimeError("preview flip_vertical deve ser bool.")
|
||||
|
||||
return {
|
||||
"rotate_deg": rotate_deg,
|
||||
"flip_horizontal": flip_horizontal,
|
||||
"flip_vertical": flip_vertical,
|
||||
}
|
||||
|
||||
|
||||
def apply_preview_orientation(
|
||||
img: np.ndarray,
|
||||
cfg: Optional[dict],
|
||||
) -> np.ndarray:
|
||||
"""
|
||||
Aplica rotação/flip SOMENTE à cópia usada para visualização.
|
||||
|
||||
Nunca usar a saída desta função em:
|
||||
- image_stats()
|
||||
- preflight_report()
|
||||
- capture_level()
|
||||
- analyze_role_sweep()
|
||||
- evaluate_reference_validation()
|
||||
- build_normalization_fragment()
|
||||
"""
|
||||
if img is None:
|
||||
return img
|
||||
|
||||
cfg = normalize_preview_orientation(cfg)
|
||||
rotate_deg = cfg["rotate_deg"]
|
||||
|
||||
if rotate_deg == 90:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_90_CLOCKWISE,
|
||||
)
|
||||
elif rotate_deg == 180:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_180,
|
||||
)
|
||||
elif rotate_deg == 270:
|
||||
img = cv2.rotate(
|
||||
img,
|
||||
cv2.ROTATE_90_COUNTERCLOCKWISE,
|
||||
)
|
||||
|
||||
if cfg["flip_horizontal"]:
|
||||
img = cv2.flip(img, 1)
|
||||
|
||||
if cfg["flip_vertical"]:
|
||||
img = cv2.flip(img, 0)
|
||||
|
||||
return np.ascontiguousarray(img)
|
||||
|
||||
|
||||
def load_optional_json(path: Optional[str]) -> dict:
|
||||
if not path:
|
||||
return {}
|
||||
|
||||
p = Path(path)
|
||||
if not p.is_file():
|
||||
return {}
|
||||
|
||||
with p.open("r", encoding="utf-8") as f:
|
||||
data = json.load(f)
|
||||
|
||||
return data if isinstance(data, dict) else {}
|
||||
|
||||
|
||||
def resolve_preview_orientation(
|
||||
specs,
|
||||
args,
|
||||
module_params: Optional[dict] = None,
|
||||
):
|
||||
"""
|
||||
Precedência:
|
||||
1. default por sensor;
|
||||
2. camera_orientation.by_role do module_params, se fornecido;
|
||||
3. override explícito por CLI.
|
||||
|
||||
Tudo continua sendo SOMENTE preview.
|
||||
"""
|
||||
module_params = module_params or {}
|
||||
|
||||
mp_orientation = module_params.get(
|
||||
"camera_orientation",
|
||||
{},
|
||||
)
|
||||
if not isinstance(mp_orientation, dict):
|
||||
mp_orientation = {}
|
||||
|
||||
mp_by_role = mp_orientation.get(
|
||||
"by_role",
|
||||
{},
|
||||
)
|
||||
if not isinstance(mp_by_role, dict):
|
||||
mp_by_role = {}
|
||||
|
||||
result = {}
|
||||
|
||||
for role in ROLES:
|
||||
sensor = str(
|
||||
specs[role].sensor_name
|
||||
or ""
|
||||
).upper()
|
||||
|
||||
cfg = dict(
|
||||
PREVIEW_ORIENTATION_BY_SENSOR.get(
|
||||
sensor,
|
||||
{
|
||||
"rotate_deg": 0,
|
||||
"flip_horizontal": False,
|
||||
"flip_vertical": False,
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
source = "sensor_default"
|
||||
|
||||
mp_role = mp_by_role.get(role)
|
||||
if isinstance(mp_role, dict):
|
||||
for key in (
|
||||
"rotate_deg",
|
||||
"flip_horizontal",
|
||||
"flip_vertical",
|
||||
):
|
||||
if key in mp_role:
|
||||
cfg[key] = mp_role[key]
|
||||
source = "module_params"
|
||||
|
||||
rotate_override = getattr(
|
||||
args,
|
||||
f"{role}_preview_rotate_deg",
|
||||
None,
|
||||
)
|
||||
if rotate_override is not None:
|
||||
cfg["rotate_deg"] = int(
|
||||
rotate_override
|
||||
)
|
||||
source = "cli_override"
|
||||
|
||||
if bool(
|
||||
getattr(
|
||||
args,
|
||||
f"{role}_preview_flip_horizontal",
|
||||
False,
|
||||
)
|
||||
):
|
||||
cfg["flip_horizontal"] = True
|
||||
source = "cli_override"
|
||||
|
||||
if bool(
|
||||
getattr(
|
||||
args,
|
||||
f"{role}_preview_flip_vertical",
|
||||
False,
|
||||
)
|
||||
):
|
||||
cfg["flip_vertical"] = True
|
||||
source = "cli_override"
|
||||
|
||||
cfg = normalize_preview_orientation(
|
||||
cfg
|
||||
)
|
||||
cfg["source"] = source
|
||||
|
||||
result[role] = cfg
|
||||
|
||||
return result
|
||||
|
||||
|
||||
def robust_median(arr: np.ndarray) -> float:
|
||||
f = np.asarray(
|
||||
arr,
|
||||
|
|
@ -1542,11 +1763,25 @@ class RadiometricRuntime:
|
|||
specs,
|
||||
args,
|
||||
qa,
|
||||
preview_orientation,
|
||||
):
|
||||
self.device = device
|
||||
self.specs = specs
|
||||
self.args = args
|
||||
self.qa = qa
|
||||
self.preview_orientation = {
|
||||
role: {
|
||||
**normalize_preview_orientation(
|
||||
(preview_orientation or {}).get(role, {})
|
||||
),
|
||||
"source": (
|
||||
(preview_orientation or {})
|
||||
.get(role, {})
|
||||
.get("source", "unknown")
|
||||
),
|
||||
}
|
||||
for role in ROLES
|
||||
}
|
||||
|
||||
self.out_q = {
|
||||
role: (
|
||||
|
|
@ -2538,11 +2773,18 @@ def show_progress_board(
|
|||
dtype=np.uint8,
|
||||
)
|
||||
else:
|
||||
# A ROI é calculada/desenhada no frame NATIVO.
|
||||
# Depois rotacionamos imagem + retângulo juntos só para display.
|
||||
view = gray_preview(
|
||||
img,
|
||||
args.roi_frac,
|
||||
)
|
||||
|
||||
view = apply_preview_orientation(
|
||||
view,
|
||||
runtime.preview_orientation[role],
|
||||
)
|
||||
|
||||
panel = resize_panel(
|
||||
view,
|
||||
pw,
|
||||
|
|
@ -2567,9 +2809,15 @@ def show_progress_board(
|
|||
[
|
||||
f"{role.upper()} | "
|
||||
f"{runtime.specs[role].sensor_name}",
|
||||
f"RAW "
|
||||
f"RAW NATIVE "
|
||||
f"{runtime.specs[role].width}x"
|
||||
f"{runtime.specs[role].height}",
|
||||
(
|
||||
f"DISPLAY ROT="
|
||||
f"{runtime.preview_orientation[role]['rotate_deg']} "
|
||||
f"FH={int(runtime.preview_orientation[role]['flip_horizontal'])} "
|
||||
f"FV={int(runtime.preview_orientation[role]['flip_vertical'])}"
|
||||
),
|
||||
f"p50={st.get('p50', 0):.3f} "
|
||||
f"p95={st.get('p95', 0):.3f}",
|
||||
f"sat={st.get('sat_pct', 0):.3f}%",
|
||||
|
|
@ -3799,6 +4047,13 @@ def build_normalization_fragment(
|
|||
role_analyses,
|
||||
args,
|
||||
):
|
||||
"""
|
||||
Gera SOMENTE radiometric_normalization.
|
||||
|
||||
camera_orientation não pertence a este fragmento:
|
||||
a calibração radiométrica mede resposta no RAW nativo e o runtime
|
||||
aplica a orientação posteriormente, depois de radiometric + flat.
|
||||
"""
|
||||
reference_controls = {}
|
||||
|
||||
scale_limits = {}
|
||||
|
|
@ -3951,6 +4206,38 @@ def main():
|
|||
default=20.0,
|
||||
)
|
||||
|
||||
parser.add_argument(
|
||||
"--module-params",
|
||||
default="calibration/module_params.json",
|
||||
help=(
|
||||
"Opcional. Lê SOMENTE camera_orientation para alinhar a UI/evidências "
|
||||
"com o módulo. Não altera a medição radiométrica."
|
||||
),
|
||||
)
|
||||
|
||||
# Orientação SOMENTE de preview/evidência.
|
||||
for role in ROLES:
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-rotate-deg",
|
||||
type=int,
|
||||
default=None,
|
||||
choices=[0, 90, 180, 270],
|
||||
help=(
|
||||
"Rotação SOMENTE da UI/evidências. "
|
||||
"Sweep/regressão permanecem no RAW nativo."
|
||||
),
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-flip-horizontal",
|
||||
action="store_true",
|
||||
help="Flip horizontal SOMENTE da UI/evidências.",
|
||||
)
|
||||
parser.add_argument(
|
||||
f"--{role}-preview-flip-vertical",
|
||||
action="store_true",
|
||||
help="Flip vertical SOMENTE da UI/evidências.",
|
||||
)
|
||||
|
||||
# Bancada
|
||||
parser.add_argument(
|
||||
"--rig-id",
|
||||
|
|
@ -4261,6 +4548,16 @@ def main():
|
|||
camera_rows
|
||||
)
|
||||
|
||||
module_params = load_optional_json(
|
||||
args.module_params
|
||||
)
|
||||
|
||||
preview_orientation = resolve_preview_orientation(
|
||||
specs,
|
||||
args,
|
||||
module_params=module_params,
|
||||
)
|
||||
|
||||
print("=" * 88)
|
||||
print(
|
||||
"RADIOMETRIC CALIBRATION - PRODUCTION"
|
||||
|
|
@ -4285,6 +4582,23 @@ def main():
|
|||
f"{s.width}x{s.height}"
|
||||
)
|
||||
|
||||
print("-" * 88)
|
||||
print(
|
||||
"[GEOMETRY] Radiometric measurement = "
|
||||
f"{CALIBRATION_DOMAIN}"
|
||||
)
|
||||
|
||||
for role in ROLES:
|
||||
o = preview_orientation[role]
|
||||
print(
|
||||
f"[PREVIEW] {role.upper():3s} | "
|
||||
f"rotate={o['rotate_deg']:3d} | "
|
||||
f"flip_h={o['flip_horizontal']} | "
|
||||
f"flip_v={o['flip_vertical']} | "
|
||||
f"source={o.get('source')} | "
|
||||
"scientific_effect=NONE"
|
||||
)
|
||||
|
||||
print("=" * 88)
|
||||
|
||||
pipeline = build_pipeline(
|
||||
|
|
@ -4341,6 +4655,25 @@ def main():
|
|||
"calibration_domain": (
|
||||
CALIBRATION_DOMAIN
|
||||
),
|
||||
"geometry_contract": {
|
||||
"radiometric_measurement_space": CALIBRATION_DOMAIN,
|
||||
"measurement_orientation": "sensor_native",
|
||||
"preview_orientation_only": True,
|
||||
"preview_orientation_by_role": {
|
||||
role: {
|
||||
"sensor": specs[role].sensor_name,
|
||||
**preview_orientation[role],
|
||||
}
|
||||
for role in ROLES
|
||||
},
|
||||
"module_params_path": args.module_params,
|
||||
"module_params_orientation_used_for_preview_only": True,
|
||||
"scientific_orientation_effect": "none",
|
||||
"runtime_order": (
|
||||
"decode -> radiometric_normalization -> flat_native -> "
|
||||
"camera_orientation -> homography"
|
||||
),
|
||||
},
|
||||
"normalization_contract": {
|
||||
"method": (
|
||||
NORMALIZATION_METHOD
|
||||
|
|
@ -4460,6 +4793,7 @@ def main():
|
|||
specs,
|
||||
args,
|
||||
qa,
|
||||
preview_orientation,
|
||||
)
|
||||
)
|
||||
|
||||
|
|
@ -4973,11 +5307,13 @@ def main():
|
|||
]
|
||||
)
|
||||
|
||||
# Salva frame de evidência no ponto de referência.
|
||||
# Salva evidência NATIVA e uma cópia DISPLAY canônica.
|
||||
# Os números de validação foram calculados exclusivamente
|
||||
# sobre runtime.images[role] no RAW nativo.
|
||||
if runtime.images[
|
||||
role
|
||||
] is not None:
|
||||
vis = gray_preview(
|
||||
vis_native = gray_preview(
|
||||
runtime.images[
|
||||
role
|
||||
],
|
||||
|
|
@ -4985,9 +5321,9 @@ def main():
|
|||
)
|
||||
|
||||
overlay_hud(
|
||||
vis,
|
||||
vis_native,
|
||||
[
|
||||
f"{role.upper()} REFERENCE VALIDATION",
|
||||
f"{role.upper()} REFERENCE VALIDATION | NATIVE",
|
||||
f"p50="
|
||||
f"{vr['measured_p50']:.4f}",
|
||||
f"target="
|
||||
|
|
@ -5001,25 +5337,85 @@ def main():
|
|||
],
|
||||
x=12,
|
||||
y=25,
|
||||
font_scale=0.55,
|
||||
line_step=23,
|
||||
font_scale=0.52,
|
||||
line_step=22,
|
||||
)
|
||||
|
||||
cv2.imwrite(
|
||||
str(
|
||||
candidate_dir
|
||||
/ (
|
||||
f"validation_"
|
||||
f"{role}.png"
|
||||
)
|
||||
/ f"validation_{role}_native.png"
|
||||
),
|
||||
vis,
|
||||
vis_native,
|
||||
)
|
||||
|
||||
vis_display = apply_preview_orientation(
|
||||
vis_native.copy(),
|
||||
preview_orientation[role],
|
||||
)
|
||||
|
||||
orient = preview_orientation[role]
|
||||
|
||||
overlay_hud(
|
||||
vis_display,
|
||||
[
|
||||
(
|
||||
f"DISPLAY ROT={orient['rotate_deg']} "
|
||||
f"FH={int(orient['flip_horizontal'])} "
|
||||
f"FV={int(orient['flip_vertical'])}"
|
||||
),
|
||||
"SCIENTIFIC MEASUREMENT=NATIVE",
|
||||
],
|
||||
x=12,
|
||||
y=138,
|
||||
font_scale=0.43,
|
||||
line_step=19,
|
||||
)
|
||||
|
||||
cv2.imwrite(
|
||||
str(
|
||||
candidate_dir
|
||||
/ f"validation_{role}_display.png"
|
||||
),
|
||||
vis_display,
|
||||
)
|
||||
|
||||
# Alias histórico permanece NATIVE.
|
||||
cv2.imwrite(
|
||||
str(
|
||||
candidate_dir
|
||||
/ f"validation_{role}.png"
|
||||
),
|
||||
vis_native,
|
||||
)
|
||||
|
||||
report[
|
||||
"reference_validation"
|
||||
] = validation_report
|
||||
|
||||
report[
|
||||
"visual_evidence"
|
||||
] = {
|
||||
role: {
|
||||
"native": str(
|
||||
candidate_dir
|
||||
/ f"validation_{role}_native.png"
|
||||
),
|
||||
"display": str(
|
||||
candidate_dir
|
||||
/ f"validation_{role}_display.png"
|
||||
),
|
||||
"legacy_alias_native": str(
|
||||
candidate_dir
|
||||
/ f"validation_{role}.png"
|
||||
),
|
||||
"preview_orientation": dict(
|
||||
preview_orientation[role]
|
||||
),
|
||||
}
|
||||
for role in ROLES
|
||||
}
|
||||
|
||||
fragment = (
|
||||
build_normalization_fragment(
|
||||
role_analyses,
|
||||
|
|
@ -5162,6 +5558,11 @@ def main():
|
|||
|
||||
lines.extend([
|
||||
"",
|
||||
(
|
||||
"Scientific space: RAW NATIVE | "
|
||||
f"RGB display rotate="
|
||||
f"{preview_orientation['rgb']['rotate_deg']} deg"
|
||||
),
|
||||
"Field radiometric controller: remains DISABLED",
|
||||
"Patch normalization: remains DISABLED",
|
||||
"",
|
||||
|
|
@ -25,6 +25,18 @@ Na arquitetura final de produto essas responsabilidades já foram separadas.
|
|||
Esta ferramenta preserva SOMENTE o que ainda é necessário ao runtime:
|
||||
camera_settings
|
||||
|
||||
Ela NÃO captura frames e NÃO transforma pixels.
|
||||
|
||||
Portanto `camera_orientation` NÃO pertence ao `camera_settings`.
|
||||
Nesta revisão a ferramenta apenas:
|
||||
- reconhece/audita `module_params.camera_orientation`, quando um MP existe;
|
||||
- registra o contrato geométrico no artefato;
|
||||
- garante que orientação nunca seja misturada em camera_settings/rgb_calibration.
|
||||
|
||||
A aplicação real de rotate/flip continua pertencendo ao RawProcessorCore,
|
||||
na etapa:
|
||||
radiometric_normalization -> flat_native -> camera_orientation -> homography
|
||||
|
||||
e força explicitamente:
|
||||
rgb_calibration.enabled = false
|
||||
gains = 1,1,1
|
||||
|
|
@ -124,6 +136,8 @@ import depthai as dai
|
|||
|
||||
|
||||
SCHEMA = "multispec_camera_startup_profile_v3"
|
||||
TOOL_VERSION = "production_orientation_aware_v1_2026_08_31"
|
||||
CAMERA_ORIENTATION_SCHEMA = "multispec_camera_orientation_v1"
|
||||
ISO_BASE = 100.0
|
||||
|
||||
ROLES = ("rgb", "re", "nir")
|
||||
|
|
@ -534,9 +548,269 @@ def build_rgb_calibration_policy() -> dict:
|
|||
}
|
||||
|
||||
|
||||
def _normalize_orientation_role(
|
||||
role: str,
|
||||
cfg: dict,
|
||||
) -> dict:
|
||||
if not isinstance(cfg, dict):
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.by_role.{role} ausente/inválido."
|
||||
)
|
||||
|
||||
raw_rotate = cfg.get("rotate_deg", 0)
|
||||
|
||||
try:
|
||||
rotate_float = float(raw_rotate)
|
||||
except Exception as exc:
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.{role}.rotate_deg inválido: {raw_rotate!r}"
|
||||
) from exc
|
||||
|
||||
if abs(rotate_float - round(rotate_float)) > 1e-9:
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.{role}.rotate_deg deve ser inteiro: "
|
||||
f"{raw_rotate!r}"
|
||||
)
|
||||
|
||||
rotate_deg = int(round(rotate_float)) % 360
|
||||
|
||||
if rotate_deg not in (0, 90, 180, 270):
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.{role}.rotate_deg inválido: {rotate_deg}. "
|
||||
"Permitidos=0,90,180,270."
|
||||
)
|
||||
|
||||
flip_horizontal = cfg.get("flip_horizontal", False)
|
||||
flip_vertical = cfg.get("flip_vertical", False)
|
||||
|
||||
if not isinstance(flip_horizontal, bool):
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.{role}.flip_horizontal deve ser bool."
|
||||
)
|
||||
|
||||
if not isinstance(flip_vertical, bool):
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.{role}.flip_vertical deve ser bool."
|
||||
)
|
||||
|
||||
return {
|
||||
"rotate_deg": rotate_deg,
|
||||
"flip_horizontal": flip_horizontal,
|
||||
"flip_vertical": flip_vertical,
|
||||
}
|
||||
|
||||
|
||||
def audit_camera_orientation_contract(
|
||||
module_params: dict,
|
||||
current_signature: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Audita camera_orientation sem assumir autoridade sobre ela.
|
||||
|
||||
Este tool NÃO gera nem aplica orientação. Ele apenas verifica um MP já
|
||||
montado, quando disponível.
|
||||
"""
|
||||
if not isinstance(module_params, dict) or not module_params:
|
||||
return {
|
||||
"available": False,
|
||||
"valid": None,
|
||||
"reason": "module_params_not_available",
|
||||
}
|
||||
|
||||
orientation = module_params.get("camera_orientation")
|
||||
|
||||
if not isinstance(orientation, dict):
|
||||
return {
|
||||
"available": False,
|
||||
"valid": None,
|
||||
"reason": "camera_orientation_not_present",
|
||||
"module_params_schema": module_params.get("schema"),
|
||||
}
|
||||
|
||||
schema = str(
|
||||
orientation.get("schema", "")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
enabled = bool(
|
||||
orientation.get("enabled", False)
|
||||
)
|
||||
|
||||
if schema != CAMERA_ORIENTATION_SCHEMA:
|
||||
raise RuntimeError(
|
||||
f"camera_orientation.schema inválido: {schema!r}. "
|
||||
f"Esperado {CAMERA_ORIENTATION_SCHEMA!r}."
|
||||
)
|
||||
|
||||
input_space = str(
|
||||
orientation.get("input_space", "")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
output_space = str(
|
||||
orientation.get("output_space", "")
|
||||
or ""
|
||||
).strip()
|
||||
|
||||
apply_stage = str(
|
||||
orientation.get("apply_stage", "")
|
||||
or ""
|
||||
).strip().lower()
|
||||
|
||||
if not input_space:
|
||||
raise RuntimeError(
|
||||
"camera_orientation.input_space ausente/vazio."
|
||||
)
|
||||
|
||||
if not output_space:
|
||||
raise RuntimeError(
|
||||
"camera_orientation.output_space ausente/vazio."
|
||||
)
|
||||
|
||||
if apply_stage != "after_native_flat_before_fusion":
|
||||
raise RuntimeError(
|
||||
"camera_orientation.apply_stage inválido: "
|
||||
f"{apply_stage!r}. Esperado "
|
||||
"'after_native_flat_before_fusion'."
|
||||
)
|
||||
|
||||
by_role = orientation.get("by_role", {}) or {}
|
||||
|
||||
if not isinstance(by_role, dict):
|
||||
raise RuntimeError(
|
||||
"camera_orientation.by_role inválido."
|
||||
)
|
||||
|
||||
normalized = {}
|
||||
has_transform = False
|
||||
|
||||
for role in ROLES:
|
||||
normalized[role] = _normalize_orientation_role(
|
||||
role,
|
||||
by_role.get(role),
|
||||
)
|
||||
|
||||
cfg = normalized[role]
|
||||
|
||||
if (
|
||||
cfg["rotate_deg"] != 0
|
||||
or cfg["flip_horizontal"]
|
||||
or cfg["flip_vertical"]
|
||||
):
|
||||
has_transform = True
|
||||
|
||||
if not enabled:
|
||||
if has_transform:
|
||||
raise RuntimeError(
|
||||
"camera_orientation.enabled=false, mas existe rotate/flip "
|
||||
"diferente de identidade."
|
||||
)
|
||||
|
||||
if input_space != output_space:
|
||||
raise RuntimeError(
|
||||
"camera_orientation.enabled=false exige "
|
||||
"input_space == output_space."
|
||||
)
|
||||
|
||||
# Se camera_hardware está presente no MP, garante que o contrato de
|
||||
# orientação pertence ao mesmo módulo/hardware conectado.
|
||||
mp_hw = module_params.get("camera_hardware")
|
||||
|
||||
if isinstance(mp_hw, dict):
|
||||
mp_sig = {}
|
||||
|
||||
for role in ROLES:
|
||||
item = mp_hw.get(role)
|
||||
|
||||
if not isinstance(item, dict):
|
||||
raise RuntimeError(
|
||||
f"camera_hardware.{role} ausente no module_params."
|
||||
)
|
||||
|
||||
size = item.get("size")
|
||||
|
||||
if not (
|
||||
isinstance(size, (list, tuple))
|
||||
and len(size) == 2
|
||||
):
|
||||
raise RuntimeError(
|
||||
f"camera_hardware.{role}.size inválido: {size}"
|
||||
)
|
||||
|
||||
mp_sig[role] = {
|
||||
"socket": str(item.get("socket") or ""),
|
||||
"sensor": str(item.get("sensor") or "").upper(),
|
||||
"size": [
|
||||
int(size[0]),
|
||||
int(size[1]),
|
||||
],
|
||||
}
|
||||
|
||||
if mp_sig != current_signature:
|
||||
raise RuntimeError(
|
||||
"camera_orientation pertence a um module_params cujo "
|
||||
"camera_hardware não corresponde ao hardware conectado.\\n"
|
||||
f"Atual: {current_signature}\\n"
|
||||
f"MP : {mp_sig}"
|
||||
)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"valid": True,
|
||||
"enabled": enabled,
|
||||
"schema": schema,
|
||||
"input_space": input_space,
|
||||
"output_space": output_space,
|
||||
"apply_stage": apply_stage,
|
||||
"by_role": normalized,
|
||||
"scientific_effect_in_this_tool": "none",
|
||||
"runtime_owner": "RawProcessorCore",
|
||||
}
|
||||
|
||||
|
||||
def extract_radiometry_geometry_audit(
|
||||
radiometry: dict,
|
||||
) -> dict:
|
||||
"""
|
||||
Lê apenas metadados geométricos do artefato radiométrico.
|
||||
|
||||
Eles são rastreabilidade. Não entram no module_params_fragment desta etapa.
|
||||
"""
|
||||
geometry = (
|
||||
radiometry.get("geometry_contract", {})
|
||||
if isinstance(radiometry, dict)
|
||||
else {}
|
||||
)
|
||||
|
||||
if not isinstance(geometry, dict) or not geometry:
|
||||
return {
|
||||
"available": False,
|
||||
}
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"radiometric_measurement_space": geometry.get(
|
||||
"radiometric_measurement_space"
|
||||
),
|
||||
"measurement_orientation": geometry.get(
|
||||
"measurement_orientation"
|
||||
),
|
||||
"preview_orientation_only": geometry.get(
|
||||
"preview_orientation_only"
|
||||
),
|
||||
"preview_orientation_by_role": geometry.get(
|
||||
"preview_orientation_by_role"
|
||||
),
|
||||
"scientific_orientation_effect": geometry.get(
|
||||
"scientific_orientation_effect"
|
||||
),
|
||||
}
|
||||
|
||||
|
||||
def compare_existing_module_params(
|
||||
module_params_path: Optional[str],
|
||||
generated_fragment: dict,
|
||||
current_signature: dict,
|
||||
) -> dict:
|
||||
if not module_params_path:
|
||||
return {
|
||||
|
|
@ -560,6 +834,11 @@ def compare_existing_module_params(
|
|||
generated_camera = generated_fragment["camera_settings"]
|
||||
generated_rgb = generated_fragment["rgb_calibration"]
|
||||
|
||||
orientation_audit = audit_camera_orientation_contract(
|
||||
current,
|
||||
current_signature,
|
||||
)
|
||||
|
||||
return {
|
||||
"available": True,
|
||||
"path": str(p),
|
||||
|
|
@ -570,6 +849,7 @@ def compare_existing_module_params(
|
|||
"generated_camera_settings": generated_camera,
|
||||
"current_rgb_calibration": current_rgb,
|
||||
"generated_rgb_calibration": generated_rgb,
|
||||
"camera_orientation_audit": orientation_audit,
|
||||
}
|
||||
|
||||
|
||||
|
|
@ -646,6 +926,10 @@ def main():
|
|||
camera_settings = build_camera_settings(reference_controls)
|
||||
rgb_calibration = build_rgb_calibration_policy()
|
||||
|
||||
radiometry_geometry_audit = extract_radiometry_geometry_audit(
|
||||
radiometry
|
||||
)
|
||||
|
||||
module_params_fragment = {
|
||||
"camera_settings": camera_settings,
|
||||
"rgb_calibration": {
|
||||
|
|
@ -661,6 +945,7 @@ def main():
|
|||
audit = compare_existing_module_params(
|
||||
args.compare_module_params,
|
||||
module_params_fragment,
|
||||
current_signature,
|
||||
)
|
||||
|
||||
payload = {
|
||||
|
|
@ -671,6 +956,27 @@ def main():
|
|||
"runtime_effect": (
|
||||
"startup_camera_policy_and_explicit_rgb_gain_disable"
|
||||
),
|
||||
"tool_version": TOOL_VERSION,
|
||||
"geometry_contract": {
|
||||
"applies_pixel_transform": False,
|
||||
"camera_orientation_generated_here": False,
|
||||
"camera_orientation_stored_in_camera_settings": False,
|
||||
"camera_orientation_runtime_owner": "RawProcessorCore",
|
||||
"runtime_order": (
|
||||
"decode -> radiometric_normalization -> flat_native -> "
|
||||
"camera_orientation -> homography"
|
||||
),
|
||||
"source_radiometry_geometry": radiometry_geometry_audit,
|
||||
"existing_module_params_orientation_audit": (
|
||||
audit.get("camera_orientation_audit")
|
||||
if isinstance(audit, dict)
|
||||
else None
|
||||
),
|
||||
"note": (
|
||||
"This startup tool never rotates/flips frames. "
|
||||
"camera_orientation is a separate geometric contract."
|
||||
),
|
||||
},
|
||||
"device_mx_id": actual_mx,
|
||||
"usb_speed": usb_speed,
|
||||
"hardware_inventory": rows,
|
||||
|
|
@ -694,6 +1000,10 @@ def main():
|
|||
"With AE enabled, manager stores exposure/gain as fallback "
|
||||
"and does not force them manually at startup."
|
||||
),
|
||||
"camera_orientation_policy": (
|
||||
"not_part_of_camera_settings; audited_only; "
|
||||
"applied_by_raw_processor_core"
|
||||
),
|
||||
},
|
||||
"rgb_calibration_policy": rgb_calibration,
|
||||
"module_params_fragment": module_params_fragment,
|
||||
|
|
@ -724,18 +1034,46 @@ def main():
|
|||
|
||||
print("-" * 82)
|
||||
print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0")
|
||||
print(
|
||||
"camera_orientation: NOT APPLIED HERE | "
|
||||
"owner=RawProcessorCore"
|
||||
)
|
||||
|
||||
if audit.get("available"):
|
||||
orientation_audit = (
|
||||
audit.get("camera_orientation_audit")
|
||||
or {}
|
||||
)
|
||||
|
||||
print(
|
||||
"module_params audit: "
|
||||
f"camera_settings_equal={audit['camera_settings_equal']} | "
|
||||
f"rgb_calibration_equal={audit['rgb_calibration_equal']}"
|
||||
f"rgb_calibration_equal={audit['rgb_calibration_equal']} | "
|
||||
f"orientation_valid={orientation_audit.get('valid')}"
|
||||
)
|
||||
|
||||
if orientation_audit.get("available"):
|
||||
for role in ROLES:
|
||||
o = (
|
||||
orientation_audit.get("by_role", {})
|
||||
.get(role, {})
|
||||
)
|
||||
|
||||
print(
|
||||
f" orientation {role.upper():3s}: "
|
||||
f"rotate={o.get('rotate_deg')} "
|
||||
f"flip_h={o.get('flip_horizontal')} "
|
||||
f"flip_v={o.get('flip_vertical')} "
|
||||
"(audit only)"
|
||||
)
|
||||
else:
|
||||
print("module_params audit: arquivo não disponível")
|
||||
|
||||
if args.check_only:
|
||||
print("[CHECK-ONLY] Nenhum arquivo alterado.")
|
||||
print(
|
||||
"[CHECK-ONLY] Nenhum arquivo alterado. "
|
||||
"Nenhuma transformação geométrica foi aplicada."
|
||||
)
|
||||
print("=" * 82)
|
||||
return
|
||||
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
Loading…
Reference in New Issue