ajustes nos scripts de calibragem e o raw processor core para compatibilidade local

This commit is contained in:
Diego Freitas 2026-09-08 22:21:39 -03:00
parent 73722a5db1
commit 744065b888
13 changed files with 5003 additions and 310 deletions

View File

@ -33,17 +33,22 @@ IMPORTANTE:
Fluxo:
1) Mostre o ChArUco nas 3 câmeras.
2) ENTER trava EXP/ISO.
3) G captura views variadas.
4) Varie X/Y, distância, yaw, pitch e roll.
5) Ideal: 18-25 views.
6) A calibra e executa acceptance.
7) PASS promove o JSON ativo.
3) R seleciona a ROI operacional da câmera escolhida (opcional).
4) Enquadre o ChArUco no quadrilátero-guia.
5) G captura e avança; F força captura fora da tolerância do guia.
6) O roteiro varia X/Y, distância, yaw, pitch e roll.
7) Ideal: completar as 25 poses guiadas.
8) A calibra e executa acceptance.
9) PASS promove o JSON ativo.
Teclas:
G captura view
G captura view quando o guia estiver READY
F força captura válida fora da tolerância do guia
A calibra/avalia
V limpa views
S snapshot
R seleciona ROI operacional da câmera escolhida
C restaura frame completo na câmera escolhida
1/2/3 seleciona câmera do preview
U liga/desliga preview undistorted após avaliação
Q/ESC cancela
@ -106,9 +111,9 @@ PREVIEW_ORIENTATION_BY_SENSOR = {
QA = {
"min_markers": 4,
"min_corners": 10,
"min_corners": 20,
"min_views": 14,
"recommended_views": 20,
"recommended_views": 25,
"min_retained_views": 12,
"min_unique_ids": 24,
@ -168,11 +173,26 @@ QA = {
"iso_tol": 5,
}
GUIDE = {
"center_tolerance_norm": 0.085,
"area_ratio_min": 0.68,
"area_ratio_max": 1.48,
"quad_rms_tolerance_norm": 0.135,
"live_detection_period_s": 0.25,
}
def now_str():
return datetime.now().strftime("%Y-%m-%d %H:%M:%S")
def calc_log(message):
print(
f"[CALC {datetime.now().strftime('%H:%M:%S')}] {message}",
flush=True,
)
def stamp():
return datetime.now().strftime("%Y%m%d_%H%M%S_%f")
@ -269,6 +289,197 @@ def apply_preview_orientation(img: np.ndarray, cfg: dict) -> np.ndarray:
return np.ascontiguousarray(img)
def oriented_size(shape_hw, cfg):
h, w = shape_hw
cfg = normalize_preview_orientation(cfg)
return (h, w) if cfg["rotate_deg"] in (90, 270) else (w, h)
def orient_points(points, shape_hw, cfg):
"""Converte pontos SENSOR-NATIVE para o preview canônico."""
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
h, w = shape_hw
cfg = normalize_preview_orientation(cfg)
rot = cfg["rotate_deg"]
x, y = pts[:, 0].copy(), pts[:, 1].copy()
if rot == 90:
pts[:, 0], pts[:, 1] = h - 1 - y, x
ow, oh = h, w
elif rot == 180:
pts[:, 0], pts[:, 1] = w - 1 - x, h - 1 - y
ow, oh = w, h
elif rot == 270:
pts[:, 0], pts[:, 1] = y, w - 1 - x
ow, oh = h, w
else:
ow, oh = w, h
if cfg["flip_horizontal"]:
pts[:, 0] = ow - 1 - pts[:, 0]
if cfg["flip_vertical"]:
pts[:, 1] = oh - 1 - pts[:, 1]
return pts
def unorient_points(points, native_shape_hw, cfg):
"""Converte pontos do preview canônico de volta ao SENSOR-NATIVE."""
pts = np.asarray(points, dtype=np.float32).reshape(-1, 2).copy()
h, w = native_shape_hw
cfg = normalize_preview_orientation(cfg)
ow, oh = oriented_size(native_shape_hw, cfg)
if cfg["flip_horizontal"]:
pts[:, 0] = ow - 1 - pts[:, 0]
if cfg["flip_vertical"]:
pts[:, 1] = oh - 1 - pts[:, 1]
x, y = pts[:, 0].copy(), pts[:, 1].copy()
rot = cfg["rotate_deg"]
if rot == 90:
pts[:, 0], pts[:, 1] = y, h - 1 - x
elif rot == 180:
pts[:, 0], pts[:, 1] = w - 1 - x, h - 1 - y
elif rot == 270:
pts[:, 0], pts[:, 1] = w - 1 - y, x
return pts
def full_roi(spec):
return {"x": 0, "y": 0, "width": int(spec.width), "height": int(spec.height)}
def normalize_roi(roi, image_size):
w, h = image_size
if roi is None:
return {"x": 0, "y": 0, "width": int(w), "height": int(h)}
x = max(0, min(int(roi["x"]), w - 1))
y = max(0, min(int(roi["y"]), h - 1))
rw = max(1, min(int(roi["width"]), w - x))
rh = max(1, min(int(roi["height"]), h - y))
if rw < max(80, int(0.15 * w)) or rh < max(80, int(0.15 * h)):
raise ValueError("ROI operacional pequena demais.")
return {"x": x, "y": y, "width": rw, "height": rh}
def roi_native_corners(roi):
x, y, w, h = roi["x"], roi["y"], roi["width"], roi["height"]
return np.asarray([
[x, y], [x + w - 1, y],
[x + w - 1, y + h - 1], [x, y + h - 1],
], dtype=np.float32)
def roi_in_preview(roi, shape_hw, orientation):
pts = orient_points(roi_native_corners(roi), shape_hw, orientation)
x0, y0 = pts.min(axis=0)
x1, y1 = pts.max(axis=0)
return {"x": float(x0), "y": float(y0),
"width": float(x1 - x0 + 1), "height": float(y1 - y0 + 1)}
def parse_roi_arg(text, spec):
if not text:
return full_roi(spec)
try:
values = [int(x.strip()) for x in str(text).split(",")]
if len(values) != 4:
raise ValueError
except ValueError as exc:
raise ValueError("ROI deve usar x,y,width,height") from exc
return normalize_roi(
{"x": values[0], "y": values[1],
"width": values[2], "height": values[3]},
(spec.width, spec.height),
)
def select_operational_roi(frame_native, role, orientation):
"""Seleção visual canônica; retorno sempre em coordenadas nativas."""
shown = apply_preview_orientation(frame_native, orientation)
oh, ow = shown.shape[:2]
scale = min(1.0, 1200.0 / ow, 760.0 / oh)
display = cv2.resize(
shown, (int(round(ow * scale)), int(round(oh * scale))),
interpolation=cv2.INTER_AREA,
)
rect = cv2.selectROI(
f"ROI operacional {role.upper()} | ENTER confirma | C cancela",
display, showCrosshair=True, fromCenter=False,
)
cv2.destroyWindow(f"ROI operacional {role.upper()} | ENTER confirma | C cancela")
x, y, rw, rh = rect
if rw <= 0 or rh <= 0:
return None
x0, y0 = x / scale, y / scale
x1, y1 = (x + rw - 1) / scale, (y + rh - 1) / scale
canonical = np.asarray([[x0, y0], [x1, y0], [x1, y1], [x0, y1]],
dtype=np.float32)
native = unorient_points(canonical, frame_native.shape[:2], orientation)
nx0, ny0 = np.floor(native.min(axis=0)).astype(int)
nx1, ny1 = np.ceil(native.max(axis=0)).astype(int)
return normalize_roi(
{"x": nx0, "y": ny0, "width": nx1 - nx0 + 1, "height": ny1 - ny0 + 1},
(frame_native.shape[1], frame_native.shape[0]),
)
def _guide_quad(cx, cy, width, height, roll=0.0, yaw=0.0, pitch=0.0):
"""Quadrilátero normalizado TL,TR,BR,BL dentro da ROI canônica."""
sx_top = 1.0 - pitch
sx_bottom = 1.0 + pitch
sy_left = 1.0 - yaw
sy_right = 1.0 + yaw
pts = np.asarray([
[-0.5 * width * sx_top, -0.5 * height * sy_left],
[ 0.5 * width * sx_top, -0.5 * height * sy_right],
[ 0.5 * width * sx_bottom, 0.5 * height * sy_right],
[-0.5 * width * sx_bottom, 0.5 * height * sy_left],
], dtype=np.float32)
a = math.radians(roll)
rot = np.asarray([[math.cos(a), -math.sin(a)],
[math.sin(a), math.cos(a)]], dtype=np.float32)
pts = pts @ rot.T
pts += np.asarray([cx, cy], dtype=np.float32)
return np.clip(pts, 0.035, 0.965).tolist()
def build_guide_targets():
raw = [
("CENTRO LONGE", .50, .50, .30, .26, 0, 0, 0),
("CENTRO MEDIO", .50, .50, .48, .41, 0, 0, 0),
("CENTRO PERTO", .50, .50, .68, .58, 0, 0, 0),
("ESQUERDA", .28, .50, .44, .38, 0, 0, 0),
("DIREITA", .72, .50, .44, .38, 0, 0, 0),
("SUPERIOR", .50, .28, .44, .38, 0, 0, 0),
("INFERIOR", .50, .72, .44, .38, 0, 0, 0),
("CANTO SUP ESQ", .28, .28, .40, .34, 0, 0, 0),
("CANTO SUP DIR", .72, .28, .40, .34, 0, 0, 0),
("CANTO INF ESQ", .28, .72, .40, .34, 0, 0, 0),
("CANTO INF DIR", .72, .72, .40, .34, 0, 0, 0),
("ESQ PERTO / INCLINE", .31, .50, .58, .49, 0, .22, 0),
("DIR PERTO / INCLINE", .69, .50, .58, .49, 0, -.22, 0),
("SUPERIOR PERTO", .50, .33, .57, .48, 0, 0, .18),
("INFERIOR PERTO", .50, .67, .57, .48, 0, 0, -.18),
("YAW ESQUERDA", .37, .48, .50, .42, 0, .28, 0),
("YAW DIREITA", .63, .52, .50, .42, 0, -.28, 0),
("YAW SUP ESQ", .34, .34, .45, .38, 0, -.24, 0),
("YAW INF DIR", .66, .66, .45, .38, 0, .24, 0),
("PITCH SUPERIOR", .48, .34, .50, .42, 0, 0, .24),
("PITCH INFERIOR", .52, .66, .50, .42, 0, 0, -.24),
("PITCH SUP DIR", .66, .35, .44, .37, 0, 0, -.22),
("PITCH INF ESQ", .34, .65, .44, .37, 0, 0, .22),
("DIAGONAL +", .43, .48, .52, .42, 14, .18, .14),
("DIAGONAL -", .57, .52, .42, .35, -14, -.18, -.14),
]
return [
{"index": i, "name": x[0],
"quad_norm": _guide_quad(*x[1:])}
for i, x in enumerate(raw, start=1)
]
def resolve_preview_orientation(specs, args):
"""
Resolve orientação visual por sensor, com override opcional por role.
@ -889,17 +1100,109 @@ def detect_triplet(triplet, board, dictionary, params):
# Pose/cobertura
# ============================================================
def pose_signature(points, shape_hw):
def order_quad(points):
pts = np.asarray(points, dtype=np.float32).reshape(4, 2)
s = pts.sum(axis=1)
d = np.diff(pts, axis=1).reshape(-1)
return np.asarray([
pts[np.argmin(s)], pts[np.argmin(d)],
pts[np.argmax(s)], pts[np.argmax(d)],
], dtype=np.float32)
def detected_board_quad(detection, board_pts):
points = detection.get("points", {})
ids = sorted(int(x) for x in points.keys())
if len(ids) < 4:
return None
src = np.asarray([board_pts[i][:2] for i in ids], dtype=np.float32)
dst = np.asarray([
points[str(i)] if str(i) in points else points[i] for i in ids
], dtype=np.float32)
H, _ = cv2.findHomography(src, dst, method=0)
if H is None:
return None
all_xy = np.asarray(board_pts, dtype=np.float32)[:, :2]
x0, y0 = all_xy.min(axis=0)
x1, y1 = all_xy.max(axis=0)
obj_quad = np.asarray(
[[[x0, y0]], [[x1, y0]], [[x1, y1]], [[x0, y1]]],
dtype=np.float32,
)
projected = cv2.perspectiveTransform(obj_quad, H).reshape(4, 2)
return order_quad(projected)
def target_quad_preview(target, roi_preview):
q = np.asarray(target["quad_norm"], dtype=np.float32)
q[:, 0] = roi_preview["x"] + q[:, 0] * roi_preview["width"]
q[:, 1] = roi_preview["y"] + q[:, 1] * roi_preview["height"]
return order_quad(q)
def assess_guide(detection, board_pts, shape_hw, orientation, roi, target):
observed_native = detected_board_quad(detection, board_pts)
if observed_native is None:
return {"ready": False, "instruction": "MOSTRE O CHARUCO",
"observed_preview": None, "target_preview": None}
observed = order_quad(orient_points(observed_native, shape_hw, orientation))
rp = roi_in_preview(roi, shape_hw, orientation)
wanted = target_quad_preview(target, rp)
diag = max(math.hypot(rp["width"], rp["height"]), 1.0)
oc = observed.mean(axis=0)
tc = wanted.mean(axis=0)
delta = (oc - tc) / np.asarray([rp["width"], rp["height"]])
center_error = float(np.linalg.norm(delta))
observed_area = abs(float(cv2.contourArea(observed)))
target_area = max(abs(float(cv2.contourArea(wanted))), 1.0)
area_ratio = observed_area / target_area
quad_rms = float(np.sqrt(np.mean(np.sum((observed - wanted) ** 2, axis=1))) / diag)
ready = (
center_error <= GUIDE["center_tolerance_norm"]
and GUIDE["area_ratio_min"] <= area_ratio <= GUIDE["area_ratio_max"]
and quad_rms <= GUIDE["quad_rms_tolerance_norm"]
)
if abs(delta[0]) > GUIDE["center_tolerance_norm"] * 0.70:
instruction = "MOVA PARA ESQUERDA" if delta[0] > 0 else "MOVA PARA DIREITA"
elif abs(delta[1]) > GUIDE["center_tolerance_norm"] * 0.70:
instruction = "MOVA PARA CIMA" if delta[1] > 0 else "MOVA PARA BAIXO"
elif area_ratio < GUIDE["area_ratio_min"]:
instruction = "APROXIME"
elif area_ratio > GUIDE["area_ratio_max"]:
instruction = "AFASTE"
elif quad_rms > GUIDE["quad_rms_tolerance_norm"]:
instruction = "AJUSTE ANGULO/PERSPECTIVA"
else:
instruction = "READY - PRESSIONE G"
return {
"ready": bool(ready),
"instruction": instruction,
"center_error_norm": center_error,
"area_ratio": float(area_ratio),
"quad_rms_norm": quad_rms,
"observed_preview": observed.tolist(),
"target_preview": wanted.tolist(),
}
def pose_signature(points, shape_hw, roi=None):
pts = np.asarray(list(points.values()), dtype=np.float32)
h, w = shape_hw
roi = normalize_roi(roi, (w, h))
x0, x1 = float(pts[:, 0].min()), float(pts[:, 0].max())
y0, y1 = float(pts[:, 1].min()), float(pts[:, 1].max())
return {
"cx": ((x0 + x1) * 0.5) / w,
"cy": ((y0 + y1) * 0.5) / h,
"area": ((x1 - x0) * (y1 - y0)) / float(w * h),
"cx": (((x0 + x1) * 0.5) - roi["x"]) / roi["width"],
"cy": (((y0 + y1) * 0.5) - roi["y"]) / roi["height"],
"area": ((x1 - x0) * (y1 - y0)) /
float(roi["width"] * roi["height"]),
}
@ -928,19 +1231,26 @@ def duplicate_pose(sig, views):
return repeated == 3
def coverage(points, image_size):
def coverage(points, image_size, roi=None):
pts = np.asarray(points, dtype=np.float32)
w, h = image_size
roi = normalize_roi(roi, image_size)
if len(pts) < 3:
return {"span_x": 0.0, "span_y": 0.0, "hull": 0.0}
return {"span_x": 0.0, "span_y": 0.0, "hull": 0.0,
"operational_roi_native": roi}
hull = cv2.convexHull(pts.reshape(-1, 1, 2))
clipped = pts.copy()
clipped[:, 0] = np.clip(clipped[:, 0], roi["x"], roi["x"] + roi["width"] - 1)
clipped[:, 1] = np.clip(clipped[:, 1], roi["y"], roi["y"] + roi["height"] - 1)
hull = cv2.convexHull(clipped.reshape(-1, 1, 2))
return {
"span_x": float((pts[:, 0].max() - pts[:, 0].min()) / w),
"span_y": float((pts[:, 1].max() - pts[:, 1].min()) / h),
"hull": float(cv2.contourArea(hull) / (w * h)),
"span_x": float((clipped[:, 0].max() - clipped[:, 0].min()) / roi["width"]),
"span_y": float((clipped[:, 1].max() - clipped[:, 1].min()) / roi["height"]),
"hull": float(cv2.contourArea(hull) /
(roi["width"] * roi["height"])),
"operational_roi_native": roi,
}
@ -1107,6 +1417,11 @@ def iterative_reject(views, role, board_pts, image_size, model_name):
rounds = []
for round_idx in range(QA["max_reject_rounds"] + 1):
calc_log(
f"{role.upper()}/{model_name}: ajuste robusto "
f"{round_idx + 1}/{QA['max_reject_rounds'] + 1} "
f"| views={len(retained)}"
)
ds = build_dataset(views, role, board_pts, retained)
if len(ds["objects"]) < QA["min_retained_views"]:
@ -1156,7 +1471,15 @@ def leave_one_out(views, role, board_pts, image_size, model_name, retained):
if len(retained) < 6:
return {"valid": False, "views": [], "overall": {}}
for held in retained:
total = len(retained)
progress_step = max(1, math.ceil(total / 5))
for held_idx, held in enumerate(retained, start=1):
if held_idx == 1 or held_idx == total or held_idx % progress_step == 0:
calc_log(
f"{role.upper()}/{model_name}: validação cruzada "
f"{held_idx}/{total}"
)
train_ids = [x for x in retained if x != held]
train = build_dataset(views, role, board_pts, train_ids)
test = build_dataset(views, role, board_pts, [held])
@ -1208,10 +1531,11 @@ def leave_one_out(views, role, board_pts, image_size, model_name, retained):
}
def distortion_shift(K, D, image_size):
def distortion_shift(K, D, image_size, roi=None):
w, h = image_size
xs = np.linspace(0, w - 1, 21)
ys = np.linspace(0, h - 1, 15)
roi = normalize_roi(roi, image_size)
xs = np.linspace(roi["x"], roi["x"] + roi["width"] - 1, 21)
ys = np.linspace(roi["y"], roi["y"] + roi["height"] - 1, 15)
pts = np.asarray([[x, y] for y in ys for x in xs],
dtype=np.float32).reshape(-1, 1, 2)
@ -1221,10 +1545,10 @@ def distortion_shift(K, D, image_size):
shift = np.linalg.norm(und - orig, axis=1)
edge = (
(orig[:, 0] < 0.15 * w)
| (orig[:, 0] > 0.85 * w)
| (orig[:, 1] < 0.15 * h)
| (orig[:, 1] > 0.85 * h)
(orig[:, 0] < roi["x"] + 0.15 * roi["width"])
| (orig[:, 0] > roi["x"] + 0.85 * roi["width"])
| (orig[:, 1] < roi["y"] + 0.15 * roi["height"])
| (orig[:, 1] > roi["y"] + 0.85 * roi["height"])
)
e = shift[edge]
@ -1236,6 +1560,7 @@ def distortion_shift(K, D, image_size):
"edge_median_px": float(np.median(e)),
"edge_p95_px": float(np.percentile(e, 95)),
"edge_max_px": float(e.max()),
"operational_roi_native": roi,
}
@ -1304,7 +1629,8 @@ def sanity(model, image_size):
}
def evaluate_model(views, role, board_pts, image_size, model_name):
def evaluate_model(views, role, board_pts, image_size, model_name,
operational_roi=None):
rej = iterative_reject(
views, role, board_pts, image_size, model_name
)
@ -1330,11 +1656,12 @@ def evaluate_model(views, role, board_pts, image_size, model_name):
for im in ds["images"]:
all_points.extend(np.asarray(im).reshape(-1, 2).tolist())
cov = coverage(all_points, image_size)
cov = coverage(all_points, image_size, operational_roi)
retained_views = [v for v in views if int(v["view_id"]) in set(rej["retained"])]
div = diversity(retained_views, role)
sane = sanity(model, image_size)
shift = distortion_shift(model["K"], model["D"], image_size)
shift = distortion_shift(model["K"], model["D"], image_size, operational_roi)
shift_full_frame = distortion_shift(model["K"], model["D"], image_size)
und = undistort_products(model["K"], model["D"], image_size)
status = sane["status"]
@ -1420,6 +1747,8 @@ def evaluate_model(views, role, board_pts, image_size, model_name):
"unique_charuco_ids": len(unique_ids),
"sanity": sane,
"distortion_shift": shift,
"distortion_shift_full_frame": shift_full_frame,
"operational_roi_native": normalize_roi(operational_roi, image_size),
"undistort_products": und,
}
@ -1457,24 +1786,55 @@ def choose_model(brown, rational, requested):
}
def evaluate_all(views, board_pts, specs, requested):
def evaluate_all(views, board_pts, specs, requested, operational_rois=None):
cameras = {}
overall = "good"
reasons = []
operational_rois = operational_rois or {
role: full_roi(specs[role]) for role in ROLES
}
for role in ROLES:
for camera_idx, role in enumerate(ROLES, start=1):
size = (specs[role].width, specs[role].height)
roi = normalize_roi(operational_rois.get(role), size)
brown = evaluate_model(
views, role, board_pts, size, "brown5"
calc_log(
f"Câmera {camera_idx}/{len(ROLES)}: {role.upper()} "
f"{size[0]}x{size[1]} | ROI="
f"{roi['x']},{roi['y']},{roi['width']},{roi['height']}"
)
started = time.perf_counter()
calc_log(f"{role.upper()}: iniciando modelo Brown5...")
brown = evaluate_model(
views, role, board_pts, size, "brown5", roi
)
calc_log(
f"{role.upper()}: Brown5 concluído em "
f"{time.perf_counter() - started:.1f}s | "
f"status={brown['status'].upper()} | "
f"reasons={' | '.join(brown.get('reasons', [])) or 'nenhuma'}"
)
started = time.perf_counter()
calc_log(f"{role.upper()}: iniciando modelo Rational8...")
rational = evaluate_model(
views, role, board_pts, size, "rational8"
views, role, board_pts, size, "rational8", roi
)
calc_log(
f"{role.upper()}: Rational8 concluído em "
f"{time.perf_counter() - started:.1f}s | "
f"status={rational['status'].upper()} | "
f"reasons={' | '.join(rational.get('reasons', [])) or 'nenhuma'}"
)
selected, selection = choose_model(
brown, rational, requested
)
calc_log(
f"{role.upper()}: selecionado {selection['selected']} "
f"| motivo={selection['reason']}"
)
edge = selected.get("distortion_shift", {}).get("edge_p95_px", 0.0)
@ -1519,6 +1879,9 @@ def module_fragment(evaluation, specs):
"sensor": specs[role].sensor,
"stream_source": specs[role].source,
"image_size": [specs[role].width, specs[role].height],
"operational_roi_native": m.get(
"operational_roi_native", full_roi(specs[role])
),
"distortion_model": cam["model_selection"]["selected"],
"camera_matrix": m["camera_matrix"],
"dist_coeffs": m["dist_coeffs"],
@ -1532,6 +1895,10 @@ def module_fragment(evaluation, specs):
"intrinsics_config": {
"enabled": True,
"calibration_space": CALIBRATION_SPACE,
"operational_roi_contract": (
"ROI afeta somente guia e acceptance. K/D e corners "
"permanecem no raster nativo completo."
),
"cameras": cameras,
"runtime_undistort": {
"enabled": False,
@ -1648,6 +2015,10 @@ def build_ui(
show_undist,
args,
preview_orientation,
operational_rois=None,
guide_role="rgb",
guide_target=None,
guide_assessment=None,
msg="",
):
"""
@ -1662,6 +2033,9 @@ def build_ui(
"re": (0, 255, 255),
"nir": (255, 255, 0),
}
operational_rois = operational_rois or {
role: full_roi(specs[role]) for role in ROLES
}
for role in ROLES:
frame_native = runtime.frames[role]
@ -1706,6 +2080,39 @@ def build_ui(
preview_orientation[role],
)
# ROI é exibida na orientação canônica, mas armazenada em SENSOR-NATIVE.
rp = roi_in_preview(
operational_rois[role], frame_native.shape[:2],
preview_orientation[role],
)
roi_poly = np.asarray([
[rp["x"], rp["y"]],
[rp["x"] + rp["width"] - 1, rp["y"]],
[rp["x"] + rp["width"] - 1, rp["y"] + rp["height"] - 1],
[rp["x"], rp["y"] + rp["height"] - 1],
], dtype=np.int32).reshape(-1, 1, 2)
cv2.polylines(bgr, [roi_poly], True, (255, 180, 0), 4, cv2.LINE_AA)
if role == guide_role and guide_target is not None and not showing_undist:
target = target_quad_preview(guide_target, rp)
cv2.polylines(
bgr, [np.rint(target).astype(np.int32).reshape(-1, 1, 2)],
True, (0, 220, 255), 6, cv2.LINE_AA,
)
if guide_assessment and guide_assessment.get("observed_preview") is not None:
observed = np.asarray(
guide_assessment["observed_preview"], dtype=np.float32
)
guide_color = (
(0, 255, 0) if guide_assessment.get("ready")
else (0, 0, 255)
)
cv2.polylines(
bgr,
[np.rint(observed).astype(np.int32).reshape(-1, 1, 2)],
True, guide_color, 6, cv2.LINE_AA,
)
p = cv2.resize(
bgr,
(pw, ph),
@ -1734,6 +2141,12 @@ def build_ui(
f"EXP={runtime.ctrl[role].get('exposure_time_us')}us "
f"ISO={runtime.ctrl[role].get('sensitivity_iso')}"
),
(
f"ROI={operational_rois[role]['x']},"
f"{operational_rois[role]['y']},"
f"{operational_rois[role]['width']},"
f"{operational_rois[role]['height']}"
),
],
x=8,
y=18,
@ -1752,12 +2165,30 @@ def build_ui(
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",
"1/2/3 select | U undist preview",
"Q/ESC cancel",
"G guided capture | F force valid",
"A calibrate | V clear | S snapshot",
"R select ROI | C full frame | 1/2/3",
"U undist preview | Q/ESC cancel",
]
if guide_target is not None:
instruction = (
guide_assessment.get("instruction", "PROCURANDO CHARUCO")
if guide_assessment else "PROCURANDO CHARUCO"
)
lines += [
"",
f"GUIDE {guide_target['index']:02d}/25 [{guide_role.upper()}]",
guide_target["name"],
instruction,
]
if guide_assessment and guide_assessment.get("center_error_norm") is not None:
lines.append(
f"center={guide_assessment['center_error_norm']:.3f} "
f"area={guide_assessment['area_ratio']:.2f} "
f"quad={guide_assessment['quad_rms_norm']:.3f}"
)
if evaluation is not None:
lines += ["", f"EVAL={evaluation['status'].upper()}"]
@ -1827,6 +2258,23 @@ def main():
ap.add_argument("--panel-height", type=int, default=400)
ap.add_argument("--preview-alpha", type=float, default=0.0,
choices=[0.0, 0.5, 1.0])
ap.add_argument(
"--guide-role", default="rgb", choices=list(ROLES),
help="Câmera usada para o enquadramento visual das poses guiadas.",
)
ap.add_argument(
"--no-guide", action="store_true",
help="Desativa o roteiro guiado; mantém captura livre por G.",
)
for role in ROLES:
ap.add_argument(
f"--{role}-operational-roi", default="",
metavar="X,Y,W,H",
help=(
"ROI operacional em coordenadas SENSOR-NATIVE. "
"Vazio usa o frame completo; também pode ser selecionada com R."
),
)
ap.add_argument(
"--candidate-root",
@ -1902,6 +2350,13 @@ def main():
rows, actual_mx, usb = discover(args.mx_id)
specs = validate_specs(rows)
preview_orientation = resolve_preview_orientation(specs, args)
operational_rois = {
role: parse_roi_arg(
getattr(args, f"{role}_operational_roi"), specs[role]
)
for role in ROLES
}
guide_targets = [] if args.no_guide else build_guide_targets()
pipeline, isp = build_pipeline(
specs,
@ -1949,6 +2404,13 @@ def main():
"usb_speed": usb,
"hardware_signature": hardware_signature(specs),
"calibration_space": CALIBRATION_SPACE,
"operational_rois_native": operational_rois,
"guided_capture": {
"enabled": not args.no_guide,
"guide_role": args.guide_role,
"thresholds": GUIDE,
"targets": guide_targets,
},
"geometry_contract": {
"intrinsics_measurement_space": CALIBRATION_SPACE,
"measurement_orientation": "sensor_native",
@ -2096,6 +2558,9 @@ def main():
show_undist = False
message = ""
message_t = 0.0
live_det = None
guide_assessment = None
last_live_detection_t = 0.0
print(
f"[CAPTURE] mínimo={QA['min_views']}, ideal={QA['recommended_views']}. "
@ -2105,13 +2570,46 @@ def main():
while True:
runtime.poll()
guide_target = (
guide_targets[min(len(views), len(guide_targets) - 1)]
if guide_targets and len(views) < len(guide_targets)
else None
)
if (
guide_target is not None
and time.time() - last_live_detection_t
>= GUIDE["live_detection_period_s"]
):
try:
live_det = detect_triplet(
{"frames": runtime.frames}, board, dictionary, params
)
guide_assessment = assess_guide(
live_det["detections"][args.guide_role],
board_pts,
runtime.frames[args.guide_role].shape[:2],
preview_orientation[args.guide_role],
operational_rois[args.guide_role],
guide_target,
)
except Exception:
live_det = None
guide_assessment = None
last_live_detection_t = time.time()
msg = message if (
message and time.time() - message_t < 4.0
) else ""
ui = build_ui(
runtime, specs, views, last_det, evaluation,
selected_role, show_undist, args, preview_orientation, msg
runtime, specs, views, live_det or last_det, evaluation,
selected_role, show_undist, args, preview_orientation,
operational_rois=operational_rois,
guide_role=args.guide_role,
guide_target=guide_target,
guide_assessment=guide_assessment,
msg=msg,
)
cv2.imshow(window, ui)
@ -2151,8 +2649,47 @@ def main():
message = f"Snapshot={snap.name}"
message_t = time.time()
elif k in (ord("g"), ord("G")):
elif k in (ord("r"), ord("R")):
if views:
message = (
"ROI bloqueada após capturas. Use V antes de alterar."
)
message_t = time.time()
continue
chosen = select_operational_roi(
runtime.frames[selected_role], selected_role,
preview_orientation[selected_role],
)
if chosen is not None:
operational_rois[selected_role] = chosen
report["operational_rois_native"] = operational_rois
save_json_atomic(report_path, report)
message = (
f"ROI {selected_role.upper()}="
f"{chosen['x']},{chosen['y']},"
f"{chosen['width']},{chosen['height']}"
)
else:
message = "Seleção de ROI cancelada."
message_t = time.time()
elif k in (ord("c"), ord("C")):
if views:
message = (
"ROI bloqueada após capturas. Use V antes de alterar."
)
else:
operational_rois[selected_role] = full_roi(
specs[selected_role]
)
report["operational_rois_native"] = operational_rois
save_json_atomic(report_path, report)
message = f"ROI {selected_role.upper()} = frame completo."
message_t = time.time()
elif k in (ord("g"), ord("G"), ord("f"), ord("F")):
try:
forced = k in (ord("f"), ord("F"))
trip = runtime.fresh_triplet()
det = detect_triplet(
trip, board, dictionary, params
@ -2164,10 +2701,34 @@ def main():
message_t = time.time()
continue
current_target = (
guide_targets[len(views)]
if guide_targets and len(views) < len(guide_targets)
else None
)
capture_assessment = None
if current_target is not None:
capture_assessment = assess_guide(
det["detections"][args.guide_role],
board_pts,
trip["frames"][args.guide_role].shape[:2],
preview_orientation[args.guide_role],
operational_rois[args.guide_role],
current_target,
)
if not forced and not capture_assessment["ready"]:
message = (
"GUIA: " + capture_assessment["instruction"]
+ " | F força captura válida"
)
message_t = time.time()
continue
pose = {
role: pose_signature(
det["detections"][role]["points"],
trip["frames"][role].shape[:2],
operational_rois[role],
)
for role in ROLES
}
@ -2189,6 +2750,15 @@ def main():
"controls": trip["controls"],
"measurement_space": CALIBRATION_SPACE,
"measurement_orientation": "sensor_native",
"operational_rois_native": {
role: dict(operational_rois[role]) for role in ROLES
},
"guided_capture": {
"forced": forced,
"guide_role": args.guide_role,
"target": current_target,
"assessment": capture_assessment,
},
"preview_orientation_by_role": {
role: dict(preview_orientation[role])
for role in ROLES
@ -2225,6 +2795,13 @@ def main():
message_t = time.time()
print("[VIEW]", message)
if guide_targets and len(views) == len(guide_targets):
message = (
f"ROTEIRO COMPLETO: {len(views)} views. "
"Pressione A para calibrar."
)
message_t = time.time()
except Exception as exc:
message = f"Captura rejeitada: {exc}"
message_t = time.time()
@ -2236,14 +2813,35 @@ def main():
)
message_t = time.time()
continue
if guide_targets and len(views) < len(guide_targets):
message = (
f"Complete o guia: {len(views)}/{len(guide_targets)} views."
)
message_t = time.time()
continue
calc_started = time.perf_counter()
calc_log(
f"Iniciando avaliação de {len(views)} views. "
"Serão avaliadas 3 câmeras e 2 modelos por câmera."
)
evaluation = evaluate_all(
views, board_pts, specs, args.distortion_model
views, board_pts, specs, args.distortion_model,
operational_rois,
)
calc_log(
f"Avaliação concluída em "
f"{time.perf_counter() - calc_started:.1f} segundos."
)
report["evaluation"] = evaluation
report["module_params_fragment"] = module_fragment(
evaluation, specs
complete_models = all(
"camera_matrix" in evaluation["cameras"][role]["selected_model"]
for role in ROLES
)
report["module_params_fragment"] = (
module_fragment(evaluation, specs)
if complete_models else None
)
save_json_atomic(report_path, report)

View File

@ -197,6 +197,7 @@ PREVIEW_ORIENTATION_BY_SENSOR = {
RAW10_MAX = 1023.0
RAW10_WHITE_SAT = 1018
RAW10_DARK_FLOOR = 16
COMPARE_EPS = 1e-6
SCHEMA = "multispec_flatfield_production_v2"
@ -1425,11 +1426,24 @@ class AcquisitionContext:
spec = self.specs[role]
# Produto: RAW10 obrigatório para estes três sensores.
raw_type = str(pkt.getType()).upper()
# RAW10 MIPI pode aparecer como RAW10 ou PACK10,
# dependendo da versão do DepthAI/firmware.
frame_type = pkt.getType()
raw_type = str(frame_type).upper()
if "RAW10" not in raw_type:
accepted_types = {
value
for value in (
getattr(dai.ImgFrame.Type, "RAW10", None),
getattr(dai.ImgFrame.Type, "PACK10", None),
)
if value is not None
}
if frame_type not in accepted_types:
raise RuntimeError(
f"{role.upper()}/{spec.sensor_name}: esperado RAW10, recebido {pkt.getType()}"
f"{role.upper()}/{spec.sensor_name}: esperado RAW10/PACK10, "
f"recebido {frame_type}"
)
width = int(pkt.getWidth())
@ -2340,24 +2354,24 @@ def evaluate_gain_channel(gain: np.ndarray, th: dict) -> dict:
status = "good"
reasons = []
if st["p99"] > th["gain_p99_bad"]:
if st["p99"] > th["gain_p99_bad"] + COMPARE_EPS:
status = merge_status(status, "bad")
reasons.append(f"gain_p99_bad:{st['p99']:.3f}")
elif st["p99"] > th["gain_p99_warning"]:
elif st["p99"] > th["gain_p99_warning"] + COMPARE_EPS:
status = merge_status(status, "warning")
reasons.append(f"gain_p99_warn:{st['p99']:.3f}")
if st["max"] > th["gain_max_bad"]:
if st["max"] > th["gain_max_bad"] + COMPARE_EPS:
status = merge_status(status, "bad")
reasons.append(f"gain_max_bad:{st['max']:.3f}")
elif st["max"] > th["gain_max_warning"]:
elif st["max"] > th["gain_max_warning"] + COMPARE_EPS:
status = merge_status(status, "warning")
reasons.append(f"gain_max_warn:{st['max']:.3f}")
if st["min"] < th["gain_min_bad"]:
if st["min"] < th["gain_min_bad"] - COMPARE_EPS:
status = merge_status(status, "bad")
reasons.append(f"gain_min_bad:{st['min']:.3f}")
elif st["min"] < th["gain_min_warning"]:
elif st["min"] < th["gain_min_warning"] - COMPARE_EPS:
status = merge_status(status, "warning")
reasons.append(f"gain_min_warn:{st['min']:.3f}")

View File

@ -236,9 +236,9 @@ PREVIEW_ORIENTATION_BY_SENSOR = {
},
}
SCHEMA = "multispec_radiometric_calibration_v5"
SCHEMA = "multispec_radiometric_calibration_v6"
CALIBRATION_DOMAIN = "native_sensor_raw_linear"
NORMALIZATION_METHOD = "oak_ae_frame_controls_v1"
NORMALIZATION_METHOD = "oak_ae_frame_controls_affine_v2"
FACTOR_MODEL = "exposure_time_us_x_iso"
ISO_BASE = 100.0
@ -270,8 +270,8 @@ DEFAULT_QA = {
# Regressão
"r2_warning": 0.995,
"r2_bad": 0.985,
"intercept_fraction_warning": 0.035,
"intercept_fraction_bad": 0.080,
"black_offset_warning": 0.10,
"black_offset_bad": 0.20,
# Validação do modelo de normalização
"norm_median_rel_error_warning": 0.030,
@ -302,14 +302,15 @@ DEFAULT_QA = {
DEFAULT_SWEEP_FACTORS = (
0.30,
0.42,
0.55,
0.70,
0.85,
1.00,
1.18,
1.38,
1.58,
0.45,
0.65,
0.90,
1.20,
1.60,
2.10,
2.80,
3.60,
4.20,
)
@ -1083,7 +1084,10 @@ def decode_raw_packet(
packet.getType()
).upper()
if "RAW10" in raw_type:
if (
"RAW10" in raw_type
or "PACK10" in raw_type
):
raw = unpack_raw10(
packet.getData(),
width,
@ -2282,6 +2286,18 @@ class RadiometricRuntime:
role
]
),
"requested_controls": {
"exposure_time_us": int(
controls_by_role[role].exposure_time_us
),
"sensitivity_iso": int(
controls_by_role[role].sensitivity_iso
),
},
"last_actual_controls": dict(
self.controls[role]
),
}
return out
@ -3247,10 +3263,18 @@ def analyze_role_sweep(
)
)
# Testa exatamente o modelo utilizado no runtime:
# normalized = measured * reference_factor / actual_factor
# Normalização considerando o pedestal preto do sensor:
#
# normalized =
# intercept
# + (measured - intercept)
# * reference_factor
# / actual_factor
normalized_values = (
y
intercept
+ (
y - intercept
)
* reference_factor
/ x
)
@ -3321,36 +3345,18 @@ def analyze_role_sweep(
f"r2_warn:{r2:.6f}"
)
if (
intercept_fraction
> qa[
"intercept_fraction_bad"
]
):
status = merge_status(
status,
"bad",
)
black_offset = abs(intercept)
if black_offset > qa["black_offset_bad"]:
status = merge_status(status, "bad")
reasons.append(
"intercept_fraction_bad:"
f"{intercept_fraction:.4f}"
)
elif (
intercept_fraction
> qa[
"intercept_fraction_warning"
]
):
status = merge_status(
status,
"warning",
f"black_offset_bad:{black_offset:.4f}"
)
elif black_offset > qa["black_offset_warning"]:
status = merge_status(status, "warning")
reasons.append(
"intercept_fraction_warn:"
f"{intercept_fraction:.4f}"
f"black_offset_warn:{black_offset:.4f}"
)
rel_med = float(
@ -4102,6 +4108,13 @@ def build_normalization_fragment(
),
}
black_offset_by_role = {
role: float(
role_analyses[role]["fit"]["intercept"]
)
for role in ROLES
}
return {
"radiometric_normalization": {
"enabled": True,
@ -4132,6 +4145,12 @@ def build_normalization_fragment(
),
"clip_output": False,
"save_debug": True,
"black_offset_model": "per_role_scalar_raw01",
"black_offset_by_role": black_offset_by_role,
"formula": (
"offset + (value - offset) * "
"reference_factor / actual_factor"
),
}
}
@ -5230,47 +5249,64 @@ def main():
role: LockedControl(
role=role,
exposure_time_us=int(
role_analyses[
role
][
"reference"
][
"exposure_time_us"
]
role_analyses[role]["reference"]["exposure_time_us"]
),
sensitivity_iso=int(
role_analyses[
role
][
"reference"
][
"sensitivity_iso"
]
),
source=(
"radiometric_reference_validation"
role_analyses[role]["reference"]["sensitivity_iso"]
),
source="radiometric_reference_validation",
)
for role in ROLES
}
runtime.send_controls(
reference_controls
)
# Validação independente por câmera.
# Para RE/NIR, coloca as duas OV9282 no mesmo controle durante
# a validação, evitando interferência ou acoplamento de exposição.
validation_stats = {}
runtime.verify_controls(
reference_controls,
settle_frames=(
args.settle_frames
),
)
for validation_role in ROLES:
validation_controls = dict(reference_controls)
validation_stats = (
runtime.capture_level(
args.validation_frames,
reference_controls,
if validation_role in ("re", "nir"):
selected = reference_controls[validation_role]
for mono_role in ("re", "nir"):
validation_controls[mono_role] = LockedControl(
role=mono_role,
exposure_time_us=selected.exposure_time_us,
sensitivity_iso=selected.sensitivity_iso,
source=(
"radiometric_reference_validation_"
f"{validation_role}"
),
)
print(
f"[VALIDATION] {validation_role.upper()} | "
f"EXP={validation_controls[validation_role].exposure_time_us}us "
f"ISO={validation_controls[validation_role].sensitivity_iso}"
)
)
# Reenvia para robustez.
for _ in range(3):
runtime.send_controls(validation_controls)
time.sleep(0.05)
runtime.verify_controls(
validation_controls,
settle_frames=max(
int(args.settle_frames),
15,
),
)
captured = runtime.capture_level(
args.validation_frames,
validation_controls,
)
# Guarda somente a câmera que está sendo validada nesta rodada.
validation_stats[validation_role] = captured[validation_role]
validation_report = {}

View File

@ -432,10 +432,10 @@ def validate_radiometry_artifact(
):
schema = str(data.get("schema", ""))
if schema != "multispec_radiometric_calibration_v5":
if schema != "multispec_radiometric_calibration_v6":
raise RuntimeError(
f"Schema radiométrico inesperado: {schema!r}. "
"Esperado 'multispec_radiometric_calibration_v5'."
"Esperado 'multispec_radiometric_calibration_v6'."
)
status = str(data.get("status", "")).lower()

View File

@ -302,7 +302,7 @@ DEFAULT_QA = {
# Overlap útil das imagens
"overlap_warning": 0.80,
"overlap_bad": 0.70,
"overlap_bad": 0.65,
# Sanidade matricial
"condition_number_warning": 1.0e5,

View File

@ -158,7 +158,7 @@ ASSEMBLER_SCHEMA = "multispec_module_params_assembly_v1"
EXPECTED_SCHEMAS = {
"focus": "multispec_focus_qc_v3",
"flatfield": "multispec_flatfield_production_v2",
"radiometry": "multispec_radiometric_calibration_v5",
"radiometry": "multispec_radiometric_calibration_v6",
"startup": "multispec_camera_startup_profile_v3",
"intrinsics": "multispec_intrinsics_calibration_v1",
"homography": "multispec_homography_calibration_v4",
@ -478,13 +478,23 @@ def normalize_role_item(
)
if size is None:
width = item.get(
"width"
)
# Formato padrão.
width = item.get("width")
height = item.get("height")
height = item.get(
"height"
)
# Formato emitido pelo Focus Calibration.
if width is None:
width = item.get("configured_width")
if height is None:
height = item.get("configured_height")
# Último fallback: dimensão anunciada pelo hardware.
if width is None:
width = item.get("feature_width")
if height is None:
height = item.get("feature_height")
if (
width is not None
@ -1111,16 +1121,46 @@ def validate_radiometry(
"Radiometric normalization homologada, mas disabled."
)
if (
cfg.get(
"method"
)
!= "oak_ae_frame_controls_v1"
):
method = str(
cfg.get("method") or ""
)
allowed_methods = {
"oak_ae_frame_controls_v1",
"oak_ae_frame_controls_affine_v2",
}
if method not in allowed_methods:
raise RuntimeError(
f"Método radiométrico inesperado: {cfg.get('method')}"
f"Método radiométrico inesperado: {method}"
)
# O affine_v2 exige um offset preto para cada câmera.
if method == "oak_ae_frame_controls_affine_v2":
offsets = (
cfg.get("black_offset_by_role", {})
or {}
)
for role in ROLES:
if role not in offsets:
raise RuntimeError(
f"Radiometry affine_v2 sem black_offset para {role}."
)
try:
offset = float(offsets[role])
except Exception as exc:
raise RuntimeError(
f"black_offset inválido para {role}: "
f"{offsets.get(role)!r}"
) from exc
if not (0.0 <= offset < 0.50):
raise RuntimeError(
f"black_offset implausível para {role}: {offset}"
)
if (
cfg.get(
"factor_model"

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,902 @@
{
"schema": "multispec_module_params_v3",
"saved_at": "2026-09-08 17:02:25",
"frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO",
"raw_policy": "allow_single",
"sensor_width": 1920,
"sensor_height": 1200,
"bayer_pattern": "GRBG",
"sensor_size_by_role": {
"rgb": [
1920,
1200
],
"re": [
1280,
800
],
"nir": [
1280,
800
]
},
"camera_hardware": {
"rgb": {
"socket": "CAM_A",
"sensor": "AR0234",
"size": [
1920,
1200
]
},
"re": {
"socket": "CAM_B",
"sensor": "OV9282",
"size": [
1280,
800
]
},
"nir": {
"socket": "CAM_C",
"sensor": "OV9282",
"size": [
1280,
800
]
}
},
"camera_orientation": {
"schema": "multispec_camera_orientation_v1",
"enabled": true,
"input_space": "native_stream_no_external_undistort",
"output_space": "canonical_oriented_stream_no_external_undistort",
"apply_stage": "after_native_flat_before_fusion",
"by_role": {
"rgb": {
"rotate_deg": 180,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1920,
1200
],
"oriented_size": [
1920,
1200
]
},
"re": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
},
"nir": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
}
}
},
"rgb_processing": {
"mode": "linear_demosaic",
"demosaic_algorithm": "ea"
},
"camera_settings": {
"rgb": {
"ae_enable": true,
"awb_enable": true,
"exposure_time_us": 631,
"analogue_gain": 1.0,
"colour_gains": [
1.0,
1.0
]
},
"re": {
"ae_enable": true,
"awb_enable": false,
"exposure_time_us": 694,
"analogue_gain": 1.0,
"colour_gains": null
},
"nir": {
"ae_enable": true,
"awb_enable": false,
"exposure_time_us": 1048,
"analogue_gain": 1.0,
"colour_gains": null
}
},
"fusion_config": {
"alignment_mode": "homography",
"baseline_mm": 75.0,
"reference_camera": "rgb",
"homography_profile": "media",
"homography_profile_by_role": {
"re": "media",
"nir": "media"
},
"homography_profiles": {
"media": {
"description": "Plano de calibração ChArUco.",
"depth": 66.0,
"depth_unit": "cm",
"homography_source": "charuco_oriented_multi_sample_group_cv",
"native_coordinate_space": "native_stream_no_external_undistort",
"coordinate_space": "canonical_oriented_stream_no_external_undistort",
"camera_orientation": {
"schema": "multispec_camera_orientation_v1",
"enabled": true,
"input_space": "native_stream_no_external_undistort",
"output_space": "canonical_oriented_stream_no_external_undistort",
"apply_stage": "after_native_flat_before_fusion",
"by_role": {
"rgb": {
"rotate_deg": 180,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1920,
1200
],
"oriented_size": [
1920,
1200
]
},
"re": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
},
"nir": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
}
}
},
"reference_size": [
1920,
1200
],
"source_size_by_role": {
"re": [
1280,
800
],
"nir": [
1280,
800
]
},
"homography_calibration_size": [
1920,
1200
],
"homography_stats": {
"re_total_points": 456,
"re_inliers": 456,
"re_inlier_pct": 100.0,
"nir_total_points": 575,
"nir_inliers": 575,
"nir_inlier_pct": 100.0,
"re_frames_used": 9,
"nir_frames_used": 9,
"common_frames_used": 9,
"re_fit_median_error_px": 0.38758599758148193,
"nir_fit_median_error_px": 0.35749897360801697,
"re_cv_median_error_px": 0.5654353499412537,
"nir_cv_median_error_px": 0.45016223192214966,
"re_overlap_pct": 67.63589409722222,
"nir_overlap_pct": 66.90980902777778,
"overlap_common_pct": 66.90980902777778
},
"homographies": {
"re_to_rgb": [
[
1.229438054753987,
-0.023146873859868553,
191.17641195023432
],
[
0.0068483713406142345,
1.215811502284338,
145.9976466757219
],
[
2.6951263574907942e-06,
-1.7656569375171528e-05,
1.0
]
],
"nir_to_rgb": [
[
1.1985871377943254,
-0.029840166013639494,
172.11865189505187
],
[
-0.005034022942030897,
1.1882575097648682,
109.71619856540441
],
[
-7.354123495506985e-06,
-3.0667664963341365e-05,
1.0
]
]
}
}
},
"homography_calibration_size": [
1920,
1200
],
"use_remap_cache": true,
"use_remap_for_rgb": false,
"use_remap_for_spec": true,
"crop_valid_common": true,
"resize_after_crop": true,
"target_size": null
},
"intrinsics_config": {
"enabled": true,
"calibration_space": "native_stream_no_external_undistort",
"operational_roi_contract": "ROI afeta somente guia e acceptance. K/D e corners permanecem no raster nativo completo.",
"cameras": {
"rgb": {
"socket": "CAM_A",
"sensor": "AR0234",
"stream_source": "ISP",
"image_size": [
1920,
1200
],
"operational_roi_native": {
"x": 0,
"y": 0,
"width": 1920,
"height": 1200
},
"distortion_model": "brown5",
"camera_matrix": [
[
1166.0979784837227,
0.0,
951.5767142097442
],
[
0.0,
1168.7643259842318,
604.1279550529616
],
[
0.0,
0.0,
1.0
]
],
"dist_coeffs": [
0.039391307619914294,
0.01630033368807952,
0.0009880549612935289,
-0.00013672753943063363,
-0.06837030351164153
],
"undistort_products": {
"0.0": {
"alpha": 0.0,
"new_camera_matrix": [
[
1181.756418296315,
0.0,
950.9467122149669
],
[
0.0,
1186.0086782696878,
605.3891162485107
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
0,
0,
1919,
1199
]
},
"0.5": {
"alpha": 0.5,
"new_camera_matrix": [
[
1168.6413181324342,
0.0,
950.1682075221445
],
[
0.0,
1172.1119850711932,
605.9709290057904
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
10,
8,
1898,
1185
]
},
"1.0": {
"alpha": 1.0,
"new_camera_matrix": [
[
1155.5262179685533,
0.0,
949.3897028293222
],
[
0.0,
1158.2152918726986,
606.55274176307
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
20,
15,
1876,
1171
]
}
}
},
"re": {
"socket": "CAM_B",
"sensor": "OV9282",
"stream_source": "MONO_OUT",
"image_size": [
1280,
800
],
"operational_roi_native": {
"x": 0,
"y": 0,
"width": 1280,
"height": 800
},
"distortion_model": "brown5",
"camera_matrix": [
[
929.0286416157797,
0.0,
644.1026229147756
],
[
0.0,
931.8815590920124,
371.82566695003317
],
[
0.0,
0.0,
1.0
]
],
"dist_coeffs": [
0.06986318959627301,
-0.013902621968219604,
0.001143262770443756,
0.0007927956015904726,
-0.07759630612045954
],
"undistort_products": {
"0.0": {
"alpha": 0.0,
"new_camera_matrix": [
[
949.6779297598379,
0.0,
645.1710508964194
],
[
0.0,
952.5670641843998,
372.7899174010669
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
0,
0,
1279,
799
]
},
"0.5": {
"alpha": 0.5,
"new_camera_matrix": [
[
948.0566137041557,
0.0,
645.3356001200425
],
[
0.0,
947.784668586652,
372.8618574128032
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
1,
2,
1277,
795
]
},
"1.0": {
"alpha": 1.0,
"new_camera_matrix": [
[
946.4352976484735,
0.0,
645.5001493436657
],
[
0.0,
943.0022729889041,
372.93379742453953
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
3,
4,
1275,
791
]
}
}
},
"nir": {
"socket": "CAM_C",
"sensor": "OV9282",
"stream_source": "MONO_OUT",
"image_size": [
1280,
800
],
"operational_roi_native": {
"x": 0,
"y": 0,
"width": 1280,
"height": 800
},
"distortion_model": "brown5",
"camera_matrix": [
[
931.8852709072933,
0.0,
655.5142810977045
],
[
0.0,
934.1328900504861,
421.25400633846596
],
[
0.0,
0.0,
1.0
]
],
"dist_coeffs": [
0.0722754387140124,
-0.04222665782931207,
0.001515428546208944,
-0.0005902584650630938,
-0.03362807732640043
],
"undistort_products": {
"0.0": {
"alpha": 0.0,
"new_camera_matrix": [
[
952.6875209672814,
0.0,
654.749436544307
],
[
0.0,
954.3413650330856,
422.427394072531
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
0,
0,
1279,
799
]
},
"0.5": {
"alpha": 0.5,
"new_camera_matrix": [
[
950.916539490878,
0.0,
654.8789818964838
],
[
0.0,
949.5012472686025,
422.0098330004064
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
1,
2,
1277,
795
]
},
"1.0": {
"alpha": 1.0,
"new_camera_matrix": [
[
949.1455580144744,
0.0,
655.0085272486606
],
[
0.0,
944.6611295041193,
421.5922719282818
],
[
0.0,
0.0,
1.0
]
],
"valid_roi": [
3,
3,
1274,
791
]
}
}
}
},
"runtime_undistort": {
"enabled": false,
"alpha": 0.0,
"interpolation": "linear",
"reason": "Homography atual foi calibrada no espaço nativo distorcido. Ative undistort somente após recalibrar a homografia no novo espaço."
}
},
"radiometric_config": {
"enabled": false,
"policy": "disabled_product_default",
"reason": "Field radiometric controller is not part of factory calibration. Per-frame exposure variation is handled by radiometric_normalization."
},
"radiometric_normalization": {
"enabled": true,
"method": "oak_ae_frame_controls_affine_v2",
"apply_stage": "after_dark_before_flat_gain",
"control_source": "stream_meta.frame_controls",
"role_mapping_source": "camera_info",
"factor_model": "exposure_time_us_x_iso",
"iso_base": 100.0,
"reference_mode": "fixed",
"reference_controls": {
"rgb": {
"exposure_time_us": 631,
"sensitivity_iso": 100
},
"re": {
"exposure_time_us": 694,
"sensitivity_iso": 100
},
"nir": {
"exposure_time_us": 1048,
"sensitivity_iso": 100
}
},
"scale_limits": {
"rgb": {
"min": 0.05,
"max": 3.0
},
"re": {
"min": 0.05,
"max": 3.0
},
"nir": {
"min": 0.05,
"max": 3.0
},
"default": {
"min": 0.05,
"max": 3.0
}
},
"missing_controls_policy": "skip",
"invalid_controls_policy": "skip",
"clip_output": false,
"save_debug": true,
"black_offset_model": "per_role_scalar_raw01",
"black_offset_by_role": {
"rgb": 0.043317917158965276,
"re": 0.07195387680152925,
"nir": 0.06753712138856606
},
"formula": "offset + (value - offset) * reference_factor / actual_factor"
},
"patch_normalization": {
"enabled": false,
"policy": "disabled_product_default",
"reason": "Reference-patch post-fusion normalization is not active in the homologated product pipeline."
},
"rgb_calibration": {
"enabled": false,
"gains": {
"R": 1.0,
"G": 1.0,
"B": 1.0
}
},
"flatfield_config": {
"enabled": true,
"npz_file": "calibration/flatfield_maps_v1.npz",
"apply_before_fusion": true,
"apply_after_decode": true,
"apply_space": "native_camera_space",
"map_type": "gain",
"channels": [
"R",
"G",
"B",
"RE",
"NIR"
],
"channel_maps": {
"R": {
"gain_key": "gain_R",
"dark_median_key": "dark_median_R"
},
"G": {
"gain_key": "gain_G",
"dark_median_key": "dark_median_G"
},
"B": {
"gain_key": "gain_B",
"dark_median_key": "dark_median_B"
},
"RE": {
"gain_key": "gain_RE",
"dark_median_key": "dark_median_RE"
},
"NIR": {
"gain_key": "gain_NIR",
"dark_median_key": "dark_median_NIR"
}
},
"subtract_dark": false,
"clip_output": true,
"json_file": "calibration/flatfield_maps_v1.json",
"schema": "multispec_flatfield_production_v2",
"created_at": "2026-09-08 16:31:07"
},
"calibration_provenance": {
"hardware_signature": {
"rgb": {
"socket": "CAM_A",
"sensor": "AR0234",
"size": [
1920,
1200
]
},
"re": {
"socket": "CAM_B",
"sensor": "OV9282",
"size": [
1280,
800
]
},
"nir": {
"socket": "CAM_C",
"sensor": "OV9282",
"size": [
1280,
800
]
}
},
"device_mx_id": "194430108133AC2F00",
"module_id": null,
"camera_orientation": {
"source": "homography.camera_orientation_signature",
"schema": "multispec_camera_orientation_v1",
"enabled": true,
"input_space": "native_stream_no_external_undistort",
"output_space": "canonical_oriented_stream_no_external_undistort",
"apply_stage": "after_native_flat_before_fusion",
"selected_homography_profile": "media"
},
"artifacts": {
"focus": {
"path": "calibration/focus_qc_active.json",
"sha256": "8c0f3ff2303a64f60b43e5a2f2966548b7720a2e43e7a46504a0a949ae6856f9",
"schema": "multispec_focus_qc_v3",
"status": "pass",
"promoted": true,
"session_id": null,
"created_at": "2026-09-08 16:03:05",
"finished_at": "2026-09-08 16:04:46"
},
"flatfield": {
"path": "calibration/flatfield_maps_v1.json",
"sha256": "1871312008b14ae4efb5d77ad7c9eb580af9c330775cccc9fa787f58f6f64648",
"schema": "multispec_flatfield_production_v2",
"status": "warning",
"promoted": true,
"session_id": "20260908_163058",
"created_at": "2026-09-08 16:31:07",
"finished_at": "2026-09-08 16:32:57",
"npz_path": "calibration/flatfield_maps_v1.npz",
"npz_sha256": "869a25192d51464a65f55852404fba01089c7aa181d975e5960a8154096cbcd4"
},
"radiometry": {
"path": "calibration/radiometry_calibration_v5.json",
"sha256": "0c9934f20a7a5e48b8c42e682e193941e841f696e799ae9834576bd9e2522ac3",
"schema": "multispec_radiometric_calibration_v6",
"status": "warning",
"promoted": true,
"session_id": "20260908_154252_449094",
"created_at": "2026-09-08 15:43:01",
"finished_at": "2026-09-08 15:44:01"
},
"startup": {
"path": "calibration/camera_startup_profile_v3.json",
"sha256": "e63e423a5d7b4fe3231352bd27e5dd3f26d0e03bb5d7c2ee6b2fe48084298599",
"schema": "multispec_camera_startup_profile_v3",
"status": "good",
"promoted": true,
"session_id": null,
"created_at": "2026-09-08 15:45:46",
"finished_at": null
},
"intrinsics": {
"path": "calibration/intrinsics_calibration_v1.json",
"sha256": "2b5d36825e40d9208cbfb7ab7f49d09c694ad26ab6f5db52fba706ab5a2d4f7d",
"schema": "multispec_intrinsics_calibration_v1",
"status": "warning_promoted",
"promoted": true,
"session_id": "20260908_144510_049990",
"created_at": "2026-09-08 14:45:15",
"finished_at": "2026-09-08 14:55:09"
},
"homography": {
"path": "calibration/homography_calibration_v4.json",
"sha256": "945af322860e5e704735cb5ac7f37918ee79e67826088e4ff13e1e54adf73378",
"schema": "multispec_homography_calibration_v4",
"status": null,
"promoted": null,
"session_id": null,
"created_at": "2026-09-08 15:11:12",
"finished_at": null,
"selected_profile": "media"
}
}
},
"assembly_metadata": {
"schema": "multispec_module_params_assembly_v1",
"assembled_at": "2026-09-08 17:02:25",
"assembly_report": "calibration/module_params_assembly_report.json",
"calibration_line": [
"focus",
"intrinsics",
"flatfield",
"radiometry",
"camera_startup",
"homography",
"module_params_assembler"
],
"runtime_undistort_enabled": false,
"camera_orientation_schema": "multispec_camera_orientation_v1",
"camera_orientation_enabled": true,
"geometry_chain": {
"intrinsics_space": "native_stream_no_external_undistort",
"orientation_input_space": "native_stream_no_external_undistort",
"orientation_output_space": "canonical_oriented_stream_no_external_undistort",
"orientation_apply_stage": "after_native_flat_before_fusion",
"homography_space": "canonical_oriented_stream_no_external_undistort",
"runtime_order": [
"decode",
"radiometric_normalization",
"flat_native",
"camera_orientation",
"homography",
"common_crop",
"final_resample"
]
}
}
}

View File

@ -0,0 +1,269 @@
{
"schema": "multispec_module_params_assembly_v1",
"created_at": "2026-09-08 17:02:25",
"status": "pass",
"check_only": false,
"output": "calibration/module_params.json",
"runtime_base_json": "calibration/module_params.json",
"runtime_base_found": false,
"hardware_signature": {
"rgb": {
"socket": "CAM_A",
"sensor": "AR0234",
"size": [
1920,
1200
]
},
"re": {
"socket": "CAM_B",
"sensor": "OV9282",
"size": [
1280,
800
]
},
"nir": {
"socket": "CAM_C",
"sensor": "OV9282",
"size": [
1280,
800
]
}
},
"device_mx_id": "194430108133AC2F00",
"module_id": null,
"bayer_pattern": "GRBG",
"homography_profile": "media",
"geometric_contract": {
"intrinsics_space": "native_stream_no_external_undistort",
"orientation_input_space": "native_stream_no_external_undistort",
"orientation_output_space": "canonical_oriented_stream_no_external_undistort",
"homography_space": "canonical_oriented_stream_no_external_undistort",
"runtime_undistort_enabled": false,
"selected_profile": "media",
"native_sizes": {
"rgb": [
1920,
1200
],
"re": [
1280,
800
],
"nir": [
1280,
800
]
},
"oriented_sizes": {
"rgb": [
1920,
1200
],
"re": [
1280,
800
],
"nir": [
1280,
800
]
},
"runtime_order": [
"decode",
"radiometric_normalization",
"flat_native",
"camera_orientation",
"homography",
"common_crop",
"final_resample"
]
},
"camera_orientation": {
"schema": "multispec_camera_orientation_v1",
"enabled": true,
"input_space": "native_stream_no_external_undistort",
"output_space": "canonical_oriented_stream_no_external_undistort",
"apply_stage": "after_native_flat_before_fusion",
"by_role": {
"rgb": {
"rotate_deg": 180,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1920,
1200
],
"oriented_size": [
1920,
1200
]
},
"re": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
},
"nir": {
"rotate_deg": 0,
"flip_horizontal": false,
"flip_vertical": false,
"native_size": [
1280,
800
],
"oriented_size": [
1280,
800
]
}
}
},
"runtime_policy": {
"onnx_model_path": null,
"frame_type": "RAW_BRUTO",
"capture_mode_requested": "AUTO",
"capture_mode_effective": "AUTO",
"raw_policy": "allow_single",
"rgb_processing": {
"mode": "bayer_planes"
},
"fusion_runtime": {
"use_remap_cache": true,
"use_remap_for_rgb": false,
"use_remap_for_spec": true,
"crop_valid_common": true,
"resize_after_crop": true,
"target_size": null
}
},
"calibration_provenance": {
"hardware_signature": {
"rgb": {
"socket": "CAM_A",
"sensor": "AR0234",
"size": [
1920,
1200
]
},
"re": {
"socket": "CAM_B",
"sensor": "OV9282",
"size": [
1280,
800
]
},
"nir": {
"socket": "CAM_C",
"sensor": "OV9282",
"size": [
1280,
800
]
}
},
"device_mx_id": "194430108133AC2F00",
"module_id": null,
"camera_orientation": {
"source": "homography.camera_orientation_signature",
"schema": "multispec_camera_orientation_v1",
"enabled": true,
"input_space": "native_stream_no_external_undistort",
"output_space": "canonical_oriented_stream_no_external_undistort",
"apply_stage": "after_native_flat_before_fusion",
"selected_homography_profile": "media"
},
"artifacts": {
"focus": {
"path": "calibration/focus_qc_active.json",
"sha256": "8c0f3ff2303a64f60b43e5a2f2966548b7720a2e43e7a46504a0a949ae6856f9",
"schema": "multispec_focus_qc_v3",
"status": "pass",
"promoted": true,
"session_id": null,
"created_at": "2026-09-08 16:03:05",
"finished_at": "2026-09-08 16:04:46"
},
"flatfield": {
"path": "calibration/flatfield_maps_v1.json",
"sha256": "1871312008b14ae4efb5d77ad7c9eb580af9c330775cccc9fa787f58f6f64648",
"schema": "multispec_flatfield_production_v2",
"status": "warning",
"promoted": true,
"session_id": "20260908_163058",
"created_at": "2026-09-08 16:31:07",
"finished_at": "2026-09-08 16:32:57",
"npz_path": "calibration/flatfield_maps_v1.npz",
"npz_sha256": "869a25192d51464a65f55852404fba01089c7aa181d975e5960a8154096cbcd4"
},
"radiometry": {
"path": "calibration/radiometry_calibration_v5.json",
"sha256": "0c9934f20a7a5e48b8c42e682e193941e841f696e799ae9834576bd9e2522ac3",
"schema": "multispec_radiometric_calibration_v6",
"status": "warning",
"promoted": true,
"session_id": "20260908_154252_449094",
"created_at": "2026-09-08 15:43:01",
"finished_at": "2026-09-08 15:44:01"
},
"startup": {
"path": "calibration/camera_startup_profile_v3.json",
"sha256": "e63e423a5d7b4fe3231352bd27e5dd3f26d0e03bb5d7c2ee6b2fe48084298599",
"schema": "multispec_camera_startup_profile_v3",
"status": "good",
"promoted": true,
"session_id": null,
"created_at": "2026-09-08 15:45:46",
"finished_at": null
},
"intrinsics": {
"path": "calibration/intrinsics_calibration_v1.json",
"sha256": "2b5d36825e40d9208cbfb7ab7f49d09c694ad26ab6f5db52fba706ab5a2d4f7d",
"schema": "multispec_intrinsics_calibration_v1",
"status": "warning_promoted",
"promoted": true,
"session_id": "20260908_144510_049990",
"created_at": "2026-09-08 14:45:15",
"finished_at": "2026-09-08 14:55:09"
},
"homography": {
"path": "calibration/homography_calibration_v4.json",
"sha256": "945af322860e5e704735cb5ac7f37918ee79e67826088e4ff13e1e54adf73378",
"schema": "multispec_homography_calibration_v4",
"status": null,
"promoted": null,
"session_id": null,
"created_at": "2026-09-08 15:11:12",
"finished_at": null,
"selected_profile": "media"
}
}
},
"final_checks": {
"focus_gate": "pass",
"flatfield": "pass",
"radiometry": "pass",
"startup": "pass",
"intrinsics": "pass",
"homography": "pass",
"hardware_consistency": "pass",
"geometry_consistency": "pass",
"camera_orientation": "pass",
"startup_radiometry_consistency": "pass",
"flatfield_npz_integrity": "pass",
"module_params_structure": "pass"
},
"output_sha256": "efcaddc1d7687cb3ed1c9c9f9f7e2a9a9a1e6c260c3af967473590dec32b313c"
}

View File

@ -28,7 +28,7 @@ module_params is loaded. The strict fail-closed behavior is enabled only for
module_params generated by the production assembler.
"""
RAW_PROCESSOR_CORE_VERSION = "production_v1_2026_08_24"
RAW_PROCESSOR_CORE_VERSION = "production_v2_2026_09_08"
import json
import os
@ -3913,20 +3913,68 @@ class RawProcessorCore:
"radiometric_normalization.enabled=true."
)
if (
str(
rad_norm.get(
"method",
"",
)
).lower()
!= "oak_ae_frame_controls_v1"
):
radiometric_method = str(
rad_norm.get(
"method",
"",
)
).lower()
allowed_radiometric_methods = {
"oak_ae_frame_controls_v1",
"oak_ae_frame_controls_affine_v2",
}
if radiometric_method not in allowed_radiometric_methods:
raise RuntimeError(
"Método radiométrico inválido no produto: "
f"{rad_norm.get('method')!r}"
)
if radiometric_method == "oak_ae_frame_controls_affine_v2":
black_offset_model = str(
rad_norm.get(
"black_offset_model",
"",
)
).lower()
if black_offset_model != "per_role_scalar_raw01":
raise RuntimeError(
"black_offset_model inválido para affine_v2: "
f"{rad_norm.get('black_offset_model')!r}. "
"Esperado='per_role_scalar_raw01'."
)
black_offsets = (
rad_norm.get(
"black_offset_by_role",
{},
)
or {}
)
for role in (
"rgb",
"re",
"nir",
):
try:
offset = float(
black_offsets[role]
)
except Exception as exc:
raise RuntimeError(
"radiometric_normalization sem black offset "
f"válido para role={role}."
) from exc
if not np.isfinite(offset) or not (0.0 <= offset < 1.0):
raise RuntimeError(
"black offset fora do domínio RAW01 em "
f"role={role}: {offset!r}"
)
factor_model = str(
rad_norm.get(
"factor_model",
@ -4464,16 +4512,45 @@ class RawProcessorCore:
# FUSION SPACE
# ============================================================
# No contrato com perfis, coordinate_space pertence ao profile ativo.
# Mantemos fallback para o campo top-level por compatibilidade legada.
fusion_space = str(
fusion.get(
"coordinate_space",
"",
)
or ""
).strip()
if not fusion_space:
selected_profile = self._resolve_homography_profile_name_for_role(
"re"
)
profiles = fusion.get(
"homography_profiles",
{},
) or {}
profile = profiles.get(selected_profile)
if profile is None and isinstance(profiles, dict):
for profile_name, profile_item in profiles.items():
if str(profile_name).lower() == str(selected_profile).lower():
profile = profile_item
break
if isinstance(profile, dict):
fusion_space = str(
profile.get(
"coordinate_space",
"",
)
or ""
).strip()
if not fusion_space:
raise RuntimeError(
"fusion_config.coordinate_space ausente."
"coordinate_space ausente no fusion_config e no "
"homography_profile ativo."
)
if (
@ -5650,11 +5727,18 @@ class RawProcessorCore:
return decoded
method = str(cfg.get("method", "oak_ae_frame_controls_v1")).lower()
if method not in ("oak_ae_frame_controls_v1", "exposure_iso_reference"):
if method not in (
"oak_ae_frame_controls_v1",
"oak_ae_frame_controls_affine_v2",
"exposure_iso_reference",
):
result["warnings"].append(f"unsupported_method:{method}")
self.last_radiometric_normalization_result = result
return decoded
affine_v2 = method == "oak_ae_frame_controls_affine_v2"
black_offset_by_role = cfg.get("black_offset_by_role", {}) or {}
controls_by_role, controls_by_cam = self._extract_frame_controls_from_meta_by_role(meta)
if not controls_by_role:
@ -5677,6 +5761,7 @@ class RawProcessorCore:
# Cache por role para não recalcular factor/scale 3x quando RGB tem 3 canais no mesmo item.
scale_by_role = {}
offset_by_role = {}
debug_by_role = {}
normalized = {}
@ -5692,6 +5777,7 @@ class RawProcessorCore:
if role in scale_by_role:
scale = scale_by_role[role]
offset = offset_by_role[role]
debug = dict(debug_by_role[role])
debug["camera_id"] = cam_id
else:
@ -5721,6 +5807,44 @@ class RawProcessorCore:
raw_scale = float(ref_factor / actual_factor)
scale = self._clip_radiometric_scale(raw_scale, role, cfg)
offset = 0.0
if affine_v2:
try:
offset = float(black_offset_by_role[role])
except Exception:
normalized[cam_id] = item
result["warnings"].append(
f"{role}:invalid_black_offset"
)
if (
getattr(self, "strict_product_contract", False)
or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise"
):
raise RuntimeError(
"Black offset radiométrico inválido ou ausente "
f"para role={role}."
)
continue
if not np.isfinite(offset) or not (0.0 <= offset < 1.0):
normalized[cam_id] = item
result["warnings"].append(
f"{role}:invalid_black_offset:{offset!r}"
)
if (
getattr(self, "strict_product_contract", False)
or str(cfg.get("invalid_controls_policy", "skip")).lower() == "raise"
):
raise RuntimeError(
"Black offset radiométrico fora do domínio RAW01 "
f"para role={role}: {offset!r}"
)
continue
debug = self._build_radiometric_debug(
method=method,
role=role,
@ -5732,16 +5856,26 @@ class RawProcessorCore:
raw_scale=raw_scale,
scale=scale,
clip_output=clip_output,
black_offset=offset if affine_v2 else None,
)
scale_by_role[role] = scale
offset_by_role[role] = offset
debug_by_role[role] = dict(debug)
out, reused = self._apply_radiometric_scale_inplace(
img,
scale=scale,
clip_output=clip_output,
)
if affine_v2:
out, reused = self._apply_radiometric_affine_inplace(
img,
scale=scale,
black_offset=offset,
clip_output=clip_output,
)
else:
out, reused = self._apply_radiometric_scale_inplace(
img,
scale=scale,
clip_output=clip_output,
)
new_item = dict(item)
new_meta = dict(item.get("meta", {}) or {})
@ -5753,6 +5887,8 @@ class RawProcessorCore:
new_meta["radiometric_normalization"] = debug
new_meta["radiometric_normalization_applied"] = True
new_meta["radiometric_normalization_scale"] = float(scale)
if affine_v2:
new_meta["radiometric_normalization_black_offset"] = float(offset)
new_item["image"] = out
new_item["meta"] = new_meta
@ -5775,6 +5911,12 @@ class RawProcessorCore:
"inplace_fast_path": True,
}
if affine_v2:
result["summary"]["black_offset_by_role"] = {
role: float(value)
for role, value in offset_by_role.items()
}
self.last_radiometric_normalization_result = result
return normalized
@ -6324,8 +6466,55 @@ class RawProcessorCore:
return out, reused
def _build_radiometric_debug(self, method, role, cam_id, actual_ctrl, ref_ctrl, actual_factor, ref_factor, raw_scale, scale, clip_output):
return {
def _apply_radiometric_affine_inplace(
self,
img,
scale: float,
black_offset: float,
clip_output: bool,
):
"""
Aplica o contrato radiométrico affine_v2 no domínio RAW01:
offset + (value - offset) * reference_factor / actual_factor
O mesmo offset escalar é aplicado aos três canais RGB da role RGB,
conforme black_offset_model='per_role_scalar_raw01'.
"""
out, reused = self._radiometric_get_writable_float32_image(img)
if out is None:
return img, False
scale = np.float32(float(scale))
offset = np.float32(float(black_offset))
# Para scale=1 a transformação é identidade, independentemente do offset.
if abs(float(scale) - 1.0) > 1e-6:
np.subtract(out, offset, out=out, casting="unsafe")
np.multiply(out, scale, out=out, casting="unsafe")
np.add(out, offset, out=out, casting="unsafe")
if clip_output:
np.clip(out, 0.0, 1.0, out=out)
return out, reused
def _build_radiometric_debug(
self,
method,
role,
cam_id,
actual_ctrl,
ref_ctrl,
actual_factor,
ref_factor,
raw_scale,
scale,
clip_output,
black_offset=None,
):
debug = {
"applied": True,
"method": method,
"role": role,
@ -6339,6 +6528,16 @@ class RawProcessorCore:
"clip_output": bool(clip_output),
}
if black_offset is not None:
debug["black_offset_model"] = "per_role_scalar_raw01"
debug["black_offset"] = float(black_offset)
debug["formula"] = (
"offset + (value - offset) * "
"reference_factor / actual_factor"
)
return debug
def _bayer_pattern_to_code_fast(self, bayer_pattern: str | None) -> int: