ajustado orientacao para ar0234 e calibragem

This commit is contained in:
Diego Freitas 2026-08-31 15:07:48 -03:00
parent a212e48c39
commit 255624a087
9 changed files with 4719 additions and 254 deletions

View File

@ -613,12 +613,248 @@ def build_reasons(
# ============================================================ # ============================================================
def load_preview_rgb(sample, chw: np.ndarray, base_module, input_channel_names: Sequence[str]) -> np.ndarray: def _preview_stretch_channel(
if getattr(sample, "preview_path", None) is not None and sample.preview_path.is_file(): ch: np.ndarray,
bgr = cv2.imread(str(sample.preview_path), cv2.IMREAD_COLOR) 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: if bgr is not None:
return cv2.cvtColor(bgr, cv2.COLOR_BGR2RGB) return cv2.cvtColor(
return base_module.tensor_to_preview_rgb(chw, input_channel_names) bgr,
cv2.COLOR_BGR2RGB,
)
# Fallback 2: método legado.
return base_module.tensor_to_preview_rgb(
chw,
input_channel_names,
)
def save_review_item( def save_review_item(

File diff suppressed because it is too large Load Diff

View File

@ -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" 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 # Descoberta e contrato de hardware
# ============================================================ # ============================================================
@ -1673,6 +1775,16 @@ class FocusRuntime:
if img is None: if img is None:
continue 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: if img.ndim == 2:
img = cv2.cvtColor( img = cv2.cvtColor(
img, img,
@ -2695,6 +2807,20 @@ def build_base_report(
"results_by_role": {}, "results_by_role": {},
"snapshots": [], "snapshots": [],
"promoted": False, "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
},
} }

View File

@ -26,6 +26,10 @@ IMPORTANTE:
Se no futuro ativarmos undistort no runtime, a homografia precisa ser Se no futuro ativarmos undistort no runtime, a homografia precisa ser
recalibrada no mesmo espaço geométrico undistorted. 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: Fluxo:
1) Mostre o ChArUco nas 3 câmeras. 1) Mostre o ChArUco nas 3 câmeras.
2) ENTER trava EXP/ISO. 2) ENTER trava EXP/ISO.
@ -80,6 +84,26 @@ MONO_MODES = {
"OV9282": ("THE_800_P", 1280, 800), "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 = { QA = {
"min_markers": 4, "min_markers": 4,
"min_corners": 10, "min_corners": 10,
@ -183,6 +207,105 @@ def safe_int(v, default=None):
return default 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): def overlay(img, lines, x=12, y=24, scale=0.45, step=19):
yy = y yy = y
for line in lines: for line in lines:
@ -1402,6 +1525,9 @@ def module_fragment(evaluation, specs):
"undistort_products": m["undistort_products"], "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 { return {
"intrinsics_config": { "intrinsics_config": {
"enabled": True, "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) 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 = [] 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: for role in ROLES:
img = frames[role] img_native = 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) bgr = (
draw_points(p, det["detections"][role]["points"], img.shape[:2], colors[role]) img_native.copy()
overlay(p, [ 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(), role.upper(),
f"markers={det['detections'][role]['markers']}", f"markers={det['detections'][role]['markers']}",
f"corners={det['detections'][role]['corners']}", 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) panels.append(p)
cv2.imwrite(str(path), np.hstack(panels)) 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) return cv2.undistort(frame, K, D, None, newK)
def build_ui(runtime, specs, views, last_det, evaluation, def build_ui(
selected_role, show_undist, args, msg=""): 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 pw, ph = args.panel_width, args.panel_height
panels = {} 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: 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: if frame_native is None:
frame = undistorted( raise RuntimeError(f"{role}: frame ausente ao montar UI.")
frame, evaluation["cameras"][role], args.preview_alpha
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) if showing_undist:
p = cv2.resize(bgr, (pw, ph), interpolation=cv2.INTER_AREA) 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( draw_points(
p, bgr,
last_det["detections"][role]["points"], last_det["detections"][role]["points"],
runtime.frames[role].shape[:2], frame_native.shape[:2],
colors[role], 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"{role.upper()} | {specs[role].sensor}",
f"{specs[role].width}x{specs[role].height}", f"native={specs[role].width}x{specs[role].height}",
"UNDIST PREVIEW" if (show_undist and role == selected_role) else "NATIVE", 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"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}", f"ISO={runtime.ctrl[role].get('sensitivity_iso')}"
], x=8, y=18, scale=0.36, step=16) ),
],
x=8,
y=18,
scale=0.34,
step=15,
)
panels[role] = p panels[role] = p
data = np.zeros((ph, pw, 3), dtype=np.uint8) data = np.zeros((ph, pw, 3), dtype=np.uint8)
selected_orient = preview_orientation[selected_role]
lines = [ lines = [
"INTRINSICS CALIBRATION - PRODUCTION", "INTRINSICS CALIBRATION - PRODUCTION",
f"views={len(views)} / min={QA['min_views']} rec={QA['recommended_views']}", 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"preview={selected_role.upper()} undist={'ON' if show_undist else 'OFF'}",
f"measurement=NATIVE | display_rot={selected_orient['rotate_deg']}",
"", "",
"G capture | A calibrate", "G capture | A calibrate",
"V clear | S snapshot", "V clear | S snapshot",
@ -1539,8 +1781,14 @@ def build_ui(runtime, specs, views, last_det, evaluation,
if msg: if msg:
cv2.putText( cv2.putText(
board, msg, (16, board.shape[0] - 14), board,
cv2.FONT_HERSHEY_SIMPLEX, 0.52, (0, 255, 0), 2, cv2.LINE_AA, msg,
(16, board.shape[0] - 14),
cv2.FONT_HERSHEY_SIMPLEX,
0.52,
(0, 255, 0),
2,
cv2.LINE_AA,
) )
return board return board
@ -1596,6 +1844,30 @@ def main():
ap.add_argument("--lens-nir", default="") ap.add_argument("--lens-nir", default="")
ap.add_argument("--notes", 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() args = ap.parse_args()
sid = stamp() sid = stamp()
@ -1629,6 +1901,7 @@ def main():
try: try:
rows, actual_mx, usb = discover(args.mx_id) rows, actual_mx, usb = discover(args.mx_id)
specs = validate_specs(rows) specs = validate_specs(rows)
preview_orientation = resolve_preview_orientation(specs, args)
pipeline, isp = build_pipeline( pipeline, isp = build_pipeline(
specs, specs,
@ -1650,6 +1923,18 @@ def main():
f"{s.width}x{s.height} | {s.source}" 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) print("=" * 82)
report = { report = {
@ -1664,6 +1949,24 @@ def main():
"usb_speed": usb, "usb_speed": usb,
"hardware_signature": hardware_signature(specs), "hardware_signature": hardware_signature(specs),
"calibration_space": CALIBRATION_SPACE, "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": { "charuco": {
"dictionary": args.charuco_dictionary, "dictionary": args.charuco_dictionary,
"squares_x": args.charuco_squares_x, "squares_x": args.charuco_squares_x,
@ -1715,8 +2018,20 @@ def main():
panels = [] panels = []
for role in ROLES: for role in ROLES:
img = runtime.frames[role] img_native = runtime.frames[role]
bgr = img if img.ndim == 3 else cv2.cvtColor(img, cv2.COLOR_GRAY2BGR) 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( p = cv2.resize(
bgr, bgr,
(args.panel_width, args.panel_height), (args.panel_width, args.panel_height),
@ -1724,12 +2039,14 @@ def main():
) )
d = det["detections"][role] d = det["detections"][role]
orient = preview_orientation[role]
overlay(p, [ overlay(p, [
f"{role.upper()} | {specs[role].sensor}", f"{role.upper()} | {specs[role].sensor}",
f"markers={d['markers']} corners={d['corners']}", 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"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}", 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) panels.append(p)
@ -1794,7 +2111,7 @@ def main():
ui = build_ui( ui = build_ui(
runtime, specs, views, last_det, evaluation, runtime, specs, views, last_det, evaluation,
selected_role, show_undist, args, msg selected_role, show_undist, args, preview_orientation, msg
) )
cv2.imshow(window, ui) cv2.imshow(window, ui)
@ -1870,6 +2187,12 @@ def main():
"captured_at": now_str(), "captured_at": now_str(),
"pose": pose, "pose": pose,
"controls": trip["controls"], "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": { "detections": {
role: { role: {
"markers": det["detections"][role]["markers"], "markers": det["detections"][role]["markers"],
@ -1885,7 +2208,7 @@ def main():
preview = views_dir / f"view_{view_id:03d}.png" preview = views_dir / f"view_{view_id:03d}.png"
save_view_preview( save_view_preview(
preview, trip["frames"], det preview, trip["frames"], det, preview_orientation
) )
view["preview_png"] = str(preview) view["preview_png"] = str(preview)
@ -1991,8 +2314,13 @@ def main():
print("[RUNTIME] runtime_undistort permanece false.") print("[RUNTIME] runtime_undistort permanece false.")
print( print(
"[IMPORTANTE] Se ativarmos undistort, recalibrar Homography " "[GEOMETRY] K/D permanecem no SENSOR NATIVE. "
"no espaço undistorted." "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(f"[ACTIVE] {active}")
print("=" * 82) print("=" * 82)

View File

@ -28,6 +28,8 @@ Princípios de produto:
12. Só promove para "active" se o acceptance test passar. 12. Só promove para "active" se o acceptance test passar.
13. Preserva calibração ativa anterior em caso de falha. 13. Preserva calibração ativa anterior em caso de falha.
14. Gera metadados, hashes, previews e sugestão de flatfield_config. 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: Dependências:
------------- -------------
@ -161,6 +163,26 @@ SUPPORTED_MONO = {
BAYER_PATTERNS = ("RGGB", "BGGR", "GRBG", "GBRG") 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
RAW10_MAX = 1023.0 RAW10_MAX = 1023.0
RAW10_WHITE_SAT = 1018 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) 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: def status_rank(status: str) -> int:
return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2) return {"good": 0, "warning": 1, "bad": 2}.get(str(status).lower(), 2)
@ -1212,6 +1370,7 @@ class AcquisitionContext:
panel_width: int, panel_width: int,
panel_height: int, panel_height: int,
rgb_bayer: str, rgb_bayer: str,
preview_orientation: Dict[str, dict],
): ):
self.device = device self.device = device
self.specs = specs self.specs = specs
@ -1219,6 +1378,12 @@ class AcquisitionContext:
self.panel_width = int(panel_width) self.panel_width = int(panel_width)
self.panel_height = int(panel_height) self.panel_height = int(panel_height)
self.rgb_bayer = rgb_bayer self.rgb_bayer = rgb_bayer
self.preview_orientation = {
role: normalize_preview_orientation(
(preview_orientation or {}).get(role, {})
)
for role in ROLES
}
self.raw_queues = { self.raw_queues = {
role: device.getOutputQueue( role: device.getOutputQueue(
@ -1449,6 +1614,12 @@ class AcquisitionContext:
u8 = raw10_display(raw) u8 = raw10_display(raw)
view = cv2.cvtColor(u8, cv2.COLOR_GRAY2BGR) 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) panel = resize_panel(view, pw, ph)
st = raw_stats(raw) st = raw_stats(raw)
@ -1458,7 +1629,12 @@ class AcquisitionContext:
panel, panel,
[ [
f"{role.upper()} | {spec.socket_name} | {spec.sensor_name}", 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"p50={st['p50']:.3f} p99={st['p99']:.3f}",
f"sat={st['sat_pct']:.3f}% dark={st['dark_pct']:.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')}", 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: except Exception:
pass 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( def create_processing_core(
specs: Dict[str, CameraSpec], specs: Dict[str, CameraSpec],
@ -1958,6 +2153,12 @@ def decode_reference(
specs: Dict[str, CameraSpec], specs: Dict[str, CameraSpec],
raw_means: Dict[str, np.ndarray], raw_means: Dict[str, np.ndarray],
) -> dict: ) -> 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 = {} frame = {}
for role in ROLES: for role in ROLES:
@ -2307,8 +2508,29 @@ def save_previews(
white_signal_a: dict, white_signal_a: dict,
white_b: dict, white_b: dict,
dark: Optional[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) 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: for ch in CHANNELS:
gain = gain_maps[ch] gain = gain_maps[ch]
@ -2318,26 +2540,60 @@ def save_previews(
validation_signal = np.maximum(wb - d, 1e-8) validation_signal = np.maximum(wb - d, 1e-8)
corrected = validation_signal * gain corrected = validation_signal * gain
cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gray_bgr(gain)) gain_native = gray_bgr(gain)
cv2.imwrite(str(out_dir / f"{ch}_white_A_signal.png"), gray_bgr(white_signal_a[ch])) white_a_native = gray_bgr(white_signal_a[ch])
cv2.imwrite(str(out_dir / f"{ch}_white_B_before.png"), gray_bgr(validation_signal)) before_native = gray_bgr(validation_signal)
cv2.imwrite(str(out_dir / f"{ch}_white_B_after.png"), gray_bgr(corrected)) after_native = gray_bgr(corrected)
# Comparativo lado a lado. # Nomes históricos continuam apontando para o espaço científico nativo.
before = gray_bgr(validation_signal) cv2.imwrite(str(out_dir / f"{ch}_gain.png"), gain_native)
after = gray_bgr(corrected) 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]) cv2.imwrite(str(out_dir / f"{ch}_gain_native.png"), gain_native)
w = min(before.shape[1], after.shape[1]) 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)) before_cmp = before_native.copy()
after = cv2.resize(after, (w, h)) 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) cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), native_side)
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_native.png"), native_side)
side = np.hstack([before, after]) # DISPLAY CANÔNICO, sem efeito científico.
cv2.imwrite(str(out_dir / f"{ch}_validation_compare.png"), side) 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}-exp-us", type=int, default=None)
parser.add_argument(f"--{role}-iso", 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 # Processamento
parser.add_argument( parser.add_argument(
"--sigma-frac", "--sigma-frac",
@ -2578,6 +2857,12 @@ def main():
rgb_spec = specs["rgb"] rgb_spec = specs["rgb"]
preview_orientation = resolve_preview_orientation(
specs,
args,
module_params=module_params,
)
print("=" * 82) print("=" * 82)
print("FLAT-FIELD CALIBRATION - PRODUCTION") print("FLAT-FIELD CALIBRATION - PRODUCTION")
print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}") print(f"DepthAI : {getattr(dai, '__version__', 'unknown')}")
@ -2593,6 +2878,18 @@ def main():
f"{s.width}x{s.height} | {s.resolution_enum}{extra}" 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) print("=" * 82)
# -------------------------------------------------------- # --------------------------------------------------------
@ -2628,6 +2925,24 @@ def main():
role: asdict(spec) role: asdict(spec)
for role, spec in specs.items() 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, "module_params_path": args.module_params,
"rgb_processing": None, "rgb_processing": None,
"capture": { "capture": {
@ -2667,6 +2982,7 @@ def main():
panel_width=args.panel_width, panel_width=args.panel_width,
panel_height=args.panel_height, panel_height=args.panel_height,
rgb_bayer=rgb_spec.bayer_pattern, rgb_bayer=rgb_spec.bayer_pattern,
preview_orientation=preview_orientation,
) )
# ---------------------------------------------------- # ----------------------------------------------------
@ -2681,6 +2997,7 @@ def main():
"enhancement_forced_off": True, "enhancement_forced_off": True,
"flatfield_forced_off": True, "flatfield_forced_off": True,
"radiometric_normalization_forced_off": True, "radiometric_normalization_forced_off": True,
"camera_orientation_forced_off_for_measurement": True,
} }
print( print(
@ -2922,6 +3239,7 @@ def main():
white_signal_a=white_signal_a, white_signal_a=white_signal_a,
white_b=white_b, white_b=white_b,
dark=dark, dark=dark,
preview_orientation=preview_orientation,
) )
report["candidate_sha256"] = sha256_file(candidate_npz) 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"RGB: {rgb_spec.sensor_name} {rgb_spec.width}x{rgb_spec.height}",
f"Bayer: {rgb_spec.bayer_pattern}", f"Bayer: {rgb_spec.bayer_pattern}",
f"RGB processing: {report['rgb_processing']['mode']}", f"RGB processing: {report['rgb_processing']['mode']}",
"Flat scientific space: NATIVE",
(
f"RGB display rotate: "
f"{preview_orientation['rgb']['rotate_deg']} deg"
),
"", "",
] ]

View File

@ -67,11 +67,17 @@ Domínio de medição
A medição é feita no RAW nativo da câmera, antes de: A medição é feita no RAW nativo da câmera, antes de:
- demosaic; - demosaic;
- enhancement; - enhancement;
- camera orientation;
- homografia; - homografia;
- flat gain; - flat gain;
- resize; - resize;
- patch normalization. - 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: Isso casa com o propósito do bloco runtime:
apply_stage = after_dark_before_flat_gain 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" SCHEMA = "multispec_radiometric_calibration_v5"
CALIBRATION_DOMAIN = "native_sensor_raw_linear" CALIBRATION_DOMAIN = "native_sensor_raw_linear"
NORMALIZATION_METHOD = "oak_ae_frame_controls_v1" 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: def robust_median(arr: np.ndarray) -> float:
f = np.asarray( f = np.asarray(
arr, arr,
@ -1542,11 +1763,25 @@ class RadiometricRuntime:
specs, specs,
args, args,
qa, qa,
preview_orientation,
): ):
self.device = device self.device = device
self.specs = specs self.specs = specs
self.args = args self.args = args
self.qa = qa 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 = { self.out_q = {
role: ( role: (
@ -2538,11 +2773,18 @@ def show_progress_board(
dtype=np.uint8, dtype=np.uint8,
) )
else: else:
# A ROI é calculada/desenhada no frame NATIVO.
# Depois rotacionamos imagem + retângulo juntos só para display.
view = gray_preview( view = gray_preview(
img, img,
args.roi_frac, args.roi_frac,
) )
view = apply_preview_orientation(
view,
runtime.preview_orientation[role],
)
panel = resize_panel( panel = resize_panel(
view, view,
pw, pw,
@ -2567,9 +2809,15 @@ def show_progress_board(
[ [
f"{role.upper()} | " f"{role.upper()} | "
f"{runtime.specs[role].sensor_name}", f"{runtime.specs[role].sensor_name}",
f"RAW " f"RAW NATIVE "
f"{runtime.specs[role].width}x" f"{runtime.specs[role].width}x"
f"{runtime.specs[role].height}", 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"p50={st.get('p50', 0):.3f} "
f"p95={st.get('p95', 0):.3f}", f"p95={st.get('p95', 0):.3f}",
f"sat={st.get('sat_pct', 0):.3f}%", f"sat={st.get('sat_pct', 0):.3f}%",
@ -3799,6 +4047,13 @@ def build_normalization_fragment(
role_analyses, role_analyses,
args, 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 = {} reference_controls = {}
scale_limits = {} scale_limits = {}
@ -3951,6 +4206,38 @@ def main():
default=20.0, 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 # Bancada
parser.add_argument( parser.add_argument(
"--rig-id", "--rig-id",
@ -4261,6 +4548,16 @@ def main():
camera_rows camera_rows
) )
module_params = load_optional_json(
args.module_params
)
preview_orientation = resolve_preview_orientation(
specs,
args,
module_params=module_params,
)
print("=" * 88) print("=" * 88)
print( print(
"RADIOMETRIC CALIBRATION - PRODUCTION" "RADIOMETRIC CALIBRATION - PRODUCTION"
@ -4285,6 +4582,23 @@ def main():
f"{s.width}x{s.height}" 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) print("=" * 88)
pipeline = build_pipeline( pipeline = build_pipeline(
@ -4341,6 +4655,25 @@ def main():
"calibration_domain": ( "calibration_domain": (
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": { "normalization_contract": {
"method": ( "method": (
NORMALIZATION_METHOD NORMALIZATION_METHOD
@ -4460,6 +4793,7 @@ def main():
specs, specs,
args, args,
qa, 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[ if runtime.images[
role role
] is not None: ] is not None:
vis = gray_preview( vis_native = gray_preview(
runtime.images[ runtime.images[
role role
], ],
@ -4985,9 +5321,9 @@ def main():
) )
overlay_hud( overlay_hud(
vis, vis_native,
[ [
f"{role.upper()} REFERENCE VALIDATION", f"{role.upper()} REFERENCE VALIDATION | NATIVE",
f"p50=" f"p50="
f"{vr['measured_p50']:.4f}", f"{vr['measured_p50']:.4f}",
f"target=" f"target="
@ -5001,25 +5337,85 @@ def main():
], ],
x=12, x=12,
y=25, y=25,
font_scale=0.55, font_scale=0.52,
line_step=23, line_step=22,
) )
cv2.imwrite( cv2.imwrite(
str( str(
candidate_dir candidate_dir
/ ( / f"validation_{role}_native.png"
f"validation_"
f"{role}.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[ report[
"reference_validation" "reference_validation"
] = validation_report ] = 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 = ( fragment = (
build_normalization_fragment( build_normalization_fragment(
role_analyses, role_analyses,
@ -5162,6 +5558,11 @@ def main():
lines.extend([ lines.extend([
"", "",
(
"Scientific space: RAW NATIVE | "
f"RGB display rotate="
f"{preview_orientation['rgb']['rotate_deg']} deg"
),
"Field radiometric controller: remains DISABLED", "Field radiometric controller: remains DISABLED",
"Patch normalization: remains DISABLED", "Patch normalization: remains DISABLED",
"", "",

View File

@ -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: Esta ferramenta preserva SOMENTE o que ainda é necessário ao runtime:
camera_settings 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: e força explicitamente:
rgb_calibration.enabled = false rgb_calibration.enabled = false
gains = 1,1,1 gains = 1,1,1
@ -124,6 +136,8 @@ import depthai as dai
SCHEMA = "multispec_camera_startup_profile_v3" 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 ISO_BASE = 100.0
ROLES = ("rgb", "re", "nir") 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( def compare_existing_module_params(
module_params_path: Optional[str], module_params_path: Optional[str],
generated_fragment: dict, generated_fragment: dict,
current_signature: dict,
) -> dict: ) -> dict:
if not module_params_path: if not module_params_path:
return { return {
@ -560,6 +834,11 @@ def compare_existing_module_params(
generated_camera = generated_fragment["camera_settings"] generated_camera = generated_fragment["camera_settings"]
generated_rgb = generated_fragment["rgb_calibration"] generated_rgb = generated_fragment["rgb_calibration"]
orientation_audit = audit_camera_orientation_contract(
current,
current_signature,
)
return { return {
"available": True, "available": True,
"path": str(p), "path": str(p),
@ -570,6 +849,7 @@ def compare_existing_module_params(
"generated_camera_settings": generated_camera, "generated_camera_settings": generated_camera,
"current_rgb_calibration": current_rgb, "current_rgb_calibration": current_rgb,
"generated_rgb_calibration": generated_rgb, "generated_rgb_calibration": generated_rgb,
"camera_orientation_audit": orientation_audit,
} }
@ -646,6 +926,10 @@ def main():
camera_settings = build_camera_settings(reference_controls) camera_settings = build_camera_settings(reference_controls)
rgb_calibration = build_rgb_calibration_policy() rgb_calibration = build_rgb_calibration_policy()
radiometry_geometry_audit = extract_radiometry_geometry_audit(
radiometry
)
module_params_fragment = { module_params_fragment = {
"camera_settings": camera_settings, "camera_settings": camera_settings,
"rgb_calibration": { "rgb_calibration": {
@ -661,6 +945,7 @@ def main():
audit = compare_existing_module_params( audit = compare_existing_module_params(
args.compare_module_params, args.compare_module_params,
module_params_fragment, module_params_fragment,
current_signature,
) )
payload = { payload = {
@ -671,6 +956,27 @@ def main():
"runtime_effect": ( "runtime_effect": (
"startup_camera_policy_and_explicit_rgb_gain_disable" "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, "device_mx_id": actual_mx,
"usb_speed": usb_speed, "usb_speed": usb_speed,
"hardware_inventory": rows, "hardware_inventory": rows,
@ -694,6 +1000,10 @@ def main():
"With AE enabled, manager stores exposure/gain as fallback " "With AE enabled, manager stores exposure/gain as fallback "
"and does not force them manually at startup." "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, "rgb_calibration_policy": rgb_calibration,
"module_params_fragment": module_params_fragment, "module_params_fragment": module_params_fragment,
@ -724,18 +1034,46 @@ def main():
print("-" * 82) print("-" * 82)
print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0") print("rgb_calibration: DISABLED | gains = 1.0 / 1.0 / 1.0")
print(
"camera_orientation: NOT APPLIED HERE | "
"owner=RawProcessorCore"
)
if audit.get("available"): if audit.get("available"):
orientation_audit = (
audit.get("camera_orientation_audit")
or {}
)
print( print(
"module_params audit: " "module_params audit: "
f"camera_settings_equal={audit['camera_settings_equal']} | " 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: else:
print("module_params audit: arquivo não disponível") print("module_params audit: arquivo não disponível")
if args.check_only: 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) print("=" * 82)
return return

File diff suppressed because it is too large Load Diff