# Ultralytics 🚀 AGPL-3.0 License - https://ultralytics.com/license

import warnings

from ultralytics.utils import DEFAULT_CFG, LOGGER, RANK, SETTINGS, TESTS_RUNNING, colorstr, torch_utils
from ultralytics.utils.torch_utils import smart_inference_mode

try:
    assert not TESTS_RUNNING  # do not log pytest
    assert SETTINGS["tensorboard"] is True  # verify integration is enabled
    WRITER = None  # TensorBoard SummaryWriter instance
    PREFIX = colorstr("TensorBoard: ")

    # Imports below only required if TensorBoard enabled
    from copy import deepcopy
    from pathlib import Path

    import cv2
    import numpy as np
    import torch
    from torch.utils.tensorboard import SummaryWriter

except (ImportError, AssertionError, TypeError, AttributeError):
    # TypeError for handling 'Descriptors cannot not be created directly.' protobuf errors in Windows
    # AttributeError: module 'tensorflow' has no attribute 'io' if 'tensorflow' not installed
    SummaryWriter = None

# Global state for validation logging
_val_sample_indices_to_log = set()
_val_seen_samples = 0
_val_index_offset = 0
_current_epoch = 0


def _reset_val_logging_state() -> None:
    """Reset validation image logging state."""
    global _val_sample_indices_to_log, _val_seen_samples, _val_index_offset
    _val_sample_indices_to_log = set()
    _val_seen_samples = 0
    _val_index_offset = 0


def _close_writer() -> None:
    """Flush and close the active TensorBoard writer if one exists."""
    global WRITER
    if WRITER:
        try:
            WRITER.flush()
        finally:
            WRITER.close()
            WRITER = None


def _sanitize_tb_tag_component(value: str, max_len: int = 80) -> str:
    """Return a TensorBoard-friendly tag component."""
    value = "".join(c if c.isalnum() or c in ("-", "_", ".") else "_" for c in str(value).strip())
    value = value.strip("._")
    return value[:max_len] if value else "unknown"


def _create_detection_image(images, targets, paths=None, names=None, is_labels=True, conf_thres=0.25):
    """Create detection visualization image for TensorBoard logging.

    Args:
        images (torch.Tensor): Batch of images (B, C, H, W)
        targets (torch.Tensor | np.ndarray): Target labels or predictions
            - For labels: (N, 6) [batch_idx, class_id, x, y, w, h] (normalized xywh)
            - For predictions: (N, 7) [batch_idx, class_id, x, y, w, h, conf] (normalized xywh)
        paths (list): Image paths
        names (dict | list): Class names
        is_labels (bool): Whether targets are ground truth labels (True) or predictions (False)
        conf_thres (float): Confidence threshold for filtering predictions

    Returns:
        np.ndarray: RGB image with bounding boxes drawn
    """
    if not WRITER:
        return None

    try:
        from ultralytics.utils.plotting import Annotator, colors
        from ultralytics.utils.ops import xywh2xyxy
        import math
        from pathlib import Path

        # Convert to numpy
        if isinstance(images, torch.Tensor):
            images = images.cpu().float().numpy()
        if isinstance(targets, torch.Tensor):
            targets = targets.cpu().numpy()

        max_size = 1920  # max image size
        max_subplots = 1  # limit to 1 image for cleaner visualization
        bs, _, h, w = images.shape  # batch size, _, height, width
        bs = min(bs, max_subplots)
        ns = np.ceil(bs**0.5)  # number of subplots (square)

        if np.max(images[0]) <= 1:
            images = images * 255  # de-normalise

        # Build Image
        mosaic = np.full((int(ns * h), int(ns * w), 3), 255, dtype=np.uint8)
        for i, im in enumerate(images):
            if i == max_subplots:
                break
            x, y = int(w * (i // ns)), int(h * (i % ns))  # block origin
            im = im.transpose(1, 2, 0)  # CHW to HWC
            # Images are in RGB format (converted from BGR in augmentation pipeline with default bgr=0.0)
            # No color conversion needed for TensorBoard
            mosaic[y : y + h, x : x + w, :] = im.astype(np.uint8)

        # Resize (optional)
        scale = max_size / ns / max(h, w)
        if scale < 1:
            h = math.ceil(scale * h)
            w = math.ceil(scale * w)
            mosaic = cv2.resize(mosaic, tuple(int(x * ns) for x in (w, h)), interpolation=cv2.INTER_AREA)

        # Annotate with thin lines
        fs = int((h + w) * ns * 0.01)  # font size
        line_width = max(1, round(fs / 30))  # thin line width
        annotator = Annotator(mosaic, line_width=line_width, font_size=fs, pil=True)

        for i in range(bs):
            x, y = int(w * (i // ns)), int(h * (i % ns))  # block origin
            annotator.rectangle([x, y, x + w, y + h], None, (255, 255, 255), width=2)  # borders
            if paths:
                annotator.text([x + 5, y + 5], text=Path(paths[i]).name[:40], txt_color=(220, 220, 220))

            if len(targets) > 0:
                ti = targets[targets[:, 0] == i]  # image targets
                boxes = xywh2xyxy(ti[:, 2:6]).T
                classes = ti[:, 1].astype("int")
                conf = None if is_labels else ti[:, 6]  # confidence only for predictions

                if boxes.shape[1]:
                    if boxes.max() <= 1.01:  # if normalized with tolerance 0.01
                        boxes[[0, 2]] *= w  # scale to pixels
                        boxes[[1, 3]] *= h
                    elif scale < 1:  # absolute coords need scale if image scales
                        boxes *= scale
                boxes[[0, 2]] += x
                boxes[[1, 3]] += y

                for j, box in enumerate(boxes.T.tolist()):
                    cls = classes[j]
                    color = colors(cls)

                    # Get class name
                    if names:
                        if isinstance(names, dict):
                            cls_name = names.get(cls, str(cls))
                        elif isinstance(names, list):
                            cls_name = names[cls] if cls < len(names) else str(cls)
                        else:
                            cls_name = str(cls)
                    else:
                        cls_name = str(cls)

                    # For predictions, show confidence score; for labels, show class name
                    if is_labels or (conf is not None and conf[j] > conf_thres):
                        if not is_labels and conf is not None:
                            label = f"{cls_name} {conf[j]:.2f}"  # Show class and confidence for predictions
                        else:
                            label = cls_name  # Show class name for ground truth
                        annotator.box_label(box, label, color=color)

        # Get result from Annotator (returns RGB when pil=True)
        result = np.asarray(annotator.result())
        return result

    except Exception as e:
        LOGGER.warning(f"{PREFIX}Failed to create detection image: {e}")
        return None


def _output_to_target(output, max_det=300):
    """Convert model output to target format for plotting.

    Args:
        output (list): List of prediction dicts or tensors
            - For dict format: [{'bboxes': (N,4), 'conf': (N,), 'cls': (N,), ...}, ...]
            - For tensor format: [(N, 6) [x1, y1, x2, y2, conf, cls], ...]
        max_det (int): Maximum detections per image

    Returns:
        np.ndarray: (M, 7) [batch_idx, class_id, x, y, w, h, conf] (normalized xywh)
    """
    try:
        from ultralytics.utils.ops import xyxy2xywh

        targets = []
        for i, o in enumerate(output):
            # Handle dict format (new postprocess output)
            if isinstance(o, dict):
                bboxes = o.get('bboxes', torch.empty((0, 4)))
                conf = o.get('conf', torch.empty((0,)))
                cls = o.get('cls', torch.empty((0,)))

                if len(bboxes) == 0:
                    continue

                # Limit detections
                if len(bboxes) > max_det:
                    bboxes = bboxes[:max_det]
                    conf = conf[:max_det]
                    cls = cls[:max_det]

                # Ensure proper shapes
                if conf.dim() == 1:
                    conf = conf.unsqueeze(-1)
                if cls.dim() == 1:
                    cls = cls.unsqueeze(-1)

                # Create batch index
                j = torch.full((len(bboxes), 1), i, device=bboxes.device)

                # Concatenate: [batch_idx, cls, xywh, conf]
                targets.append(torch.cat((j, cls, xyxy2xywh(bboxes), conf), 1))

            # Handle tensor format (legacy)
            else:
                if len(o) == 0:
                    continue
                o = o[:max_det]  # limit detections
                box, conf, cls = o[:, :4], o[:, 4:5], o[:, 5:6]
                j = torch.full((conf.shape[0], 1), i, device=o.device)  # batch index
                targets.append(torch.cat((j, cls, xyxy2xywh(box), conf), 1))

        if targets:
            return torch.cat(targets, 0).cpu().numpy()
        else:
            return np.empty((0, 7))

    except Exception as e:
        LOGGER.warning(f"{PREFIX}Failed to convert output to target: {e}")
        return np.empty((0, 7))


def _log_scalars(scalars: dict | None, step: int = 0) -> None:
    """Log scalar values to TensorBoard.

    Args:
        scalars (dict | None): Dictionary of scalar values to log to TensorBoard. Keys are scalar names and values are
            the corresponding scalar values. If None or empty, nothing is logged.
        step (int): Global step value to record with the scalar values. Used for x-axis in TensorBoard graphs.

    Examples:
        Log training metrics
        >>> metrics = {"loss": 0.5, "accuracy": 0.95}
        >>> _log_scalars(metrics, step=100)
    """
    if WRITER and scalars:
        for k, v in scalars.items():
            WRITER.add_scalar(k, v, step)


@smart_inference_mode()
def _log_tensorboard_graph(trainer) -> None:
    """Log model graph to TensorBoard.

    This function attempts to visualize the model architecture in TensorBoard by tracing the model with a dummy input
    tensor. It first tries a simple method suitable for YOLO models, and if that fails, falls back to a more complex
    approach for models like RTDETR that may require special handling.

    Args:
        trainer (ultralytics.engine.trainer.BaseTrainer): The trainer object containing the model to visualize. Must
            have attributes model and args with imgsz.

    Notes:
        This function requires TensorBoard integration to be enabled and the global WRITER to be initialized.
        It handles potential warnings from the PyTorch JIT tracer and attempts to gracefully handle different
        model architectures.
    """
    # Input image - use CPU to avoid corrupting CUDA context if tracing fails
    imgsz = trainer.args.imgsz
    imgsz = (imgsz, imgsz) if isinstance(imgsz, int) else imgsz
    im = torch.zeros((1, 3, *imgsz), device="cpu", dtype=torch.float32)

    # Try simple method first (YOLO)
    try:
        model = deepcopy(torch_utils.unwrap_model(trainer.model)).cpu().eval()
        with warnings.catch_warnings():
            warnings.filterwarnings(
                "ignore",
                message="The input to trace is already a ScriptModule, tracing it is a no-op.*",
                category=UserWarning,
            )
            WRITER.add_graph(torch.jit.trace(model, im, strict=False), [])
        LOGGER.info(f"{PREFIX}model graph visualization added ✅")
        return
    except Exception as e1:
        # Fallback to TorchScript export steps (RTDETR)
        try:
            model = deepcopy(torch_utils.unwrap_model(trainer.model)).cpu().eval()
            model = model.fuse(verbose=False)
            for m in model.modules():
                if hasattr(m, "export"):  # Detect, RTDETRDecoder (Segment and Pose use Detect base class)
                    m.export = True
                    m.format = "torchscript"
            model(im)  # dry run
            with warnings.catch_warnings():
                warnings.filterwarnings(
                    "ignore",
                    message="The input to trace is already a ScriptModule, tracing it is a no-op.*",
                    category=UserWarning,
                )
                WRITER.add_graph(torch.jit.trace(model, im, strict=False), [])
            LOGGER.info(f"{PREFIX}model graph visualization added ✅")
        except Exception as e2:
            LOGGER.warning(f"{PREFIX}TensorBoard graph visualization failure: {e1} -> {e2}")


def on_pretrain_routine_start(trainer) -> None:
    """Initialize TensorBoard logging with SummaryWriter."""
    if SummaryWriter:
        try:
            global WRITER
            WRITER = SummaryWriter(str(trainer.save_dir))
            LOGGER.info(f"{PREFIX}Start with 'tensorboard --logdir {trainer.save_dir}', view at http://localhost:6006/")
        except Exception as e:
            LOGGER.warning(f"{PREFIX}TensorBoard not initialized correctly, not logging this run. {e}")


def on_train_start(trainer) -> None:
    """Log TensorBoard graph."""
    if WRITER:
        _log_tensorboard_graph(trainer)


def on_train_epoch_end(trainer) -> None:
    """Log scalar statistics at the end of a training epoch."""
    _log_scalars(trainer.label_loss_items(trainer.tloss, prefix="train"), trainer.epoch + 1)
    _log_scalars(trainer.lr, trainer.epoch + 1)


def on_fit_epoch_end(trainer) -> None:
    """Log epoch metrics at end of training epoch."""
    _log_scalars(trainer.metrics, trainer.epoch + 1)


def on_val_start(validator) -> None:
    """Initialize validation logging - select samples to log."""
    global _val_sample_indices_to_log, _val_seen_samples, _val_index_offset
    if WRITER:
        dataset = getattr(validator.dataloader, "dataset", None)
        total_samples = len(dataset or [])
        if total_samples <= 0:
            total_samples = len(validator.dataloader)
        num_samples = min(50, total_samples)
        sample_indices = np.linspace(0, max(total_samples - 1, 0), num_samples, dtype=int).tolist()

        sampler = getattr(validator.dataloader, "sampler", None)
        if sampler and hasattr(sampler, "_get_rank_indices"):
            start_idx, end_idx = sampler._get_rank_indices()
            _val_index_offset = start_idx
            _val_sample_indices_to_log = {idx - start_idx for idx in sample_indices if start_idx <= idx < end_idx}
        else:
            _val_index_offset = 0
            _val_sample_indices_to_log = set(sample_indices)

        _val_seen_samples = 0


def on_val_batch_end(validator) -> None:
    """Log validation sample images to TensorBoard as 2x2 grid if 3D data available."""
    global _current_epoch, _val_sample_indices_to_log, _val_seen_samples, _val_index_offset

    if not WRITER or not _val_sample_indices_to_log:
        return

    try:
        batch = validator.batch
        batch_size = len(batch.get("im_file", [])) or int(batch["img"].shape[0])
        batch_start = _val_seen_samples
        batch_end = batch_start + batch_size
        sample_indices = [i for i in range(batch_start, batch_end) if i in _val_sample_indices_to_log]
        _val_seen_samples = batch_end
        if not sample_indices:
            return

        # 2x2 grid [Target 2D | Target 3D] / [Pred 2D | Pred 3D]
        for sample_idx in sample_indices:
            batch_sample_idx = sample_idx - batch_start
            _log_3d_visualization(validator, batch, batch_sample_idx, sample_idx + _val_index_offset)

    except Exception as e:
        LOGGER.warning(f"{PREFIX}Failed to log validation images: {e}")


def _draw_dashed_rectangle(img, p1, p2, color, thickness=1, dash=8, gap=5):
    """Draw a dashed rectangle on an image."""
    x1, y1 = p1
    x2, y2 = p2
    for x in range(x1, x2, dash + gap):
        cv2.line(img, (x, y1), (min(x + dash, x2), y1), color, thickness, cv2.LINE_AA)
        cv2.line(img, (x, y2), (min(x + dash, x2), y2), color, thickness, cv2.LINE_AA)
    for y in range(y1, y2, dash + gap):
        cv2.line(img, (x1, y), (x1, min(y + dash, y2)), color, thickness, cv2.LINE_AA)
        cv2.line(img, (x2, y), (x2, min(y + dash, y2)), color, thickness, cv2.LINE_AA)


def _draw_2d_boxes_on_image(img, boxes_xyxy, cls_ids, names=None, confs=None, thickness=1, diff_cls=None):
    """Draw 2D bounding boxes on an image using cv2 (BGR color conventions).

    Args:
        img: (H, W, 3) uint8 image (BGR).
        boxes_xyxy: (N, 4) array of [x1, y1, x2, y2] in pixel coords.
        cls_ids: (N,) array of class IDs.
        names: Dict or list of class names.
        confs: (N,) array of confidences, or None for GT.
        thickness: Line thickness.
    """
    from ultralytics.utils.plotting import colors as get_color

    for i in range(len(boxes_xyxy)):
        x1, y1, x2, y2 = [int(v) for v in boxes_xyxy[i]]
        cls = int(cls_ids[i])
        color = get_color(cls, bgr=True)
        is_diff1 = diff_cls is not None and int(diff_cls[i]) == 1
        if is_diff1:
            _draw_dashed_rectangle(img, (x1, y1), (x2, y2), color, thickness=max(thickness, 1))
        else:
            cv2.rectangle(img, (x1, y1), (x2, y2), color, thickness, cv2.LINE_AA)

        # Label
        if names:
            cls_name = names.get(cls, str(cls)) if isinstance(names, dict) else (
                names[cls] if isinstance(names, list) and cls < len(names) else str(cls))
        else:
            cls_name = str(cls)
        label = f"{cls_name} {confs[i]:.2f}" if confs is not None else cls_name
        if diff_cls is not None:
            label = f"{label} d{int(diff_cls[i])}"
        (tw, th), _ = cv2.getTextSize(label, cv2.FONT_HERSHEY_SIMPLEX, 0.5, 1)
        cv2.rectangle(img, (x1, y1 - th - 4), (x1 + tw, y1), color, -1)
        cv2.putText(img, label, (x1, y1 - 2), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255), 1, cv2.LINE_AA)


def _log_3d_visualization(validator, batch, batch_sample_idx=0, sample_idx=None):
    """Log 2x2 grid visualization: [Target 2D | Target 3D] / [Pred 2D | Pred 3D].

    GT depth is restored in decode_3d_target() using calib["depth_scale"].
    Prediction depth is restored once upstream in Ground3DDetectionValidator.postprocess().
    Both are projected with the original batch calibration.
    """
    labels_3d = batch.get("labels_3d")
    if labels_3d is None or len(labels_3d) == 0:
        return

    try:
        from ultralytics.utils.ops import xywh2xyxy
        from ultralytics.utils.plotting_3d import (
            collect_precomputed_edge_points_2d,
            decode_3d_prediction,
            decode_3d_target,
            draw_3d_box,
        )

        # Get 3D class config from model
        model = validator.trainer.model if hasattr(validator, "trainer") and validator.trainer else None
        if model is None:
            return
        if hasattr(model, "module"):
            model = model.module
        face_3d_classes = getattr(model, "face_3d_classes", set())
        complete_3d_classes = getattr(model, "complete_3d_classes", set())
        if not face_3d_classes and not complete_3d_classes:
            return

        images = batch["img"]
        batch_idx_t = batch.get("batch_idx")
        batch_cls_t = batch.get("cls")
        batch_bboxes = batch.get("bboxes")
        if batch_idx_t is None or batch_cls_t is None:
            return

        batch_idx_np = batch_idx_t.cpu().numpy().ravel()
        cls_np = batch_cls_t.cpu().numpy().ravel()
        labels_3d_np = labels_3d.cpu().numpy()
        edge_faces_points_2d = batch.get("edge_faces_points_2d")
        edge_faces_valid = batch.get("edge_faces_valid")
        edge_faces_points_2d_np = edge_faces_points_2d.cpu().numpy() if edge_faces_points_2d is not None else None
        edge_faces_valid_np = edge_faces_valid.cpu().numpy() if edge_faces_valid is not None else None
        face_visibility_score_thresh = float(
            getattr(validator.args, "face_visibility_score_thresh", DEFAULT_CFG.face_visibility_score_thresh)
        )
        _, _, img_h, img_w = images.shape

        batch_calib = batch.get("calib")
        calib = batch_calib[batch_sample_idx] if batch_calib is not None and batch_sample_idx < len(batch_calib) else None
        if calib is None:
            LOGGER.warning(f"{PREFIX}No calib in batch, skipping 3D visualization")
            return

        batch_camera_mode = batch.get("camera_mode")
        camera_mode = (
            str(batch_camera_mode[batch_sample_idx])
            if isinstance(batch_camera_mode, (list, tuple)) and batch_sample_idx < len(batch_camera_mode)
            else "unknown"
        )

        epoch = validator.trainer.epoch if validator.training and validator.trainer else 0
        global_sample_idx = sample_idx if sample_idx is not None else batch_sample_idx
        rank_tag = f"rank{RANK if RANK >= 0 else 0}"
        batch_im_files = batch.get("im_file")
        image_name = None
        if isinstance(batch_im_files, (list, tuple)) and batch_sample_idx < len(batch_im_files):
            image_name = Path(str(batch_im_files[batch_sample_idx])).name
        image_tag = _sanitize_tb_tag_component(Path(image_name).stem if image_name else "unknown")

        # --- Prepare base image ---
        im0 = images[batch_sample_idx].cpu().numpy().transpose(1, 2, 0)
        im0 = np.ascontiguousarray(im0 * 255, dtype=np.uint8)
        im0 = cv2.cvtColor(im0, cv2.COLOR_RGB2BGR)

        # Create 4 copies for the 2x2 grid
        im_gt_2d = im0.copy()
        im_gt_3d = im0.copy()
        im_pred_2d = im0.copy()
        im_pred_3d = im0.copy()

        line_thick = 1

        # --- GT: Decode and draw boxes for selected image ---
        mask_i = batch_idx_np == batch_sample_idx
        if batch_bboxes is not None and mask_i.any():
            gt_bboxes_xywh = batch_bboxes[mask_i].cpu().numpy()
            gt_cls = cls_np[mask_i]
            gt_bboxes_xyxy = xywh2xyxy(torch.from_numpy(gt_bboxes_xywh)).numpy()
            gt_bboxes_xyxy[:, [0, 2]] *= img_w
            gt_bboxes_xyxy[:, [1, 3]] *= img_h
            _draw_2d_boxes_on_image(im_gt_2d, gt_bboxes_xyxy, gt_cls, names=validator.names, thickness=line_thick)

        gt_indices = np.where(mask_i)[0]
        for local_idx, idx in enumerate(gt_indices):
            bbox_xyxy = gt_bboxes_xyxy[local_idx] if batch_bboxes is not None and mask_i.any() else None
            d = decode_3d_target(
                labels_3d_np[idx],
                int(cls_np[idx]),
                calib,
                img_w,
                img_h,
                face_3d_classes,
                complete_3d_classes,
                score_thr=face_visibility_score_thresh,
                bbox_xyxy=bbox_xyxy,
            )
            if d is not None and d.get("corners_3d") is not None:
                if edge_faces_points_2d_np is not None and edge_faces_valid_np is not None and idx < len(edge_faces_points_2d_np):
                    edge_points_2d = collect_precomputed_edge_points_2d(
                        edge_faces_points_2d_np[idx],
                        edge_faces_valid_np[idx],
                        visible_face_types=d.get("visible_face_types", ()),
                    )
                    if edge_points_2d is not None:
                        d = {**d, "edge_points_2d": edge_points_2d}
                draw_3d_box(
                    im_gt_3d,
                    d["corners_3d"],
                    calib,
                    d.get("face_center_2d"),
                    d.get("face_color"),
                    edge_points_2d=d.get("edge_points_2d"),
                    edge_color=(0, 255, 0),
                    thickness=line_thick,
                )

        # --- Predictions: Decode and draw boxes for selected image ---
        preds_3d_sel = getattr(validator, "_preds_3d_selected", None)
        preds_edge_sel = getattr(validator, "_preds_edge_selected", None)
        preds_diff_sel = getattr(validator, "_preds_diff_selected", None)
        anchors_sel = getattr(validator, "_anchors_selected", None)
        strides_sel = getattr(validator, "_strides_selected", None)
        preds_2d = validator.pred

        if preds_2d is not None and len(preds_2d) > batch_sample_idx:
            pred_i = preds_2d[batch_sample_idx]
            pred_bboxes = pred_i["bboxes"].cpu().numpy()
            pred_cls_np = pred_i["cls"].cpu().numpy()
            pred_conf_np = pred_i["conf"].cpu().numpy()
            display_conf = max(float(getattr(validator.args, "visualize_conf", 0.25)), float(getattr(validator.args, "conf", 0.0)))
            display_mask = pred_conf_np >= display_conf

            if display_mask.any():
                pred_diff_np = None
                if preds_diff_sel is not None:
                    diff_logits = preds_diff_sel[batch_sample_idx]
                    if diff_logits.shape[-1] == 1:
                        diff_prob = diff_logits[..., 0].sigmoid()
                        diff_thres = float(getattr(validator.args, "diff_thres", 0.7))
                        pred_diff_np = (diff_prob >= diff_thres).cpu().numpy().astype(np.int64)
                    elif diff_logits.shape[-1] == 2:
                        pred_diff_np = diff_logits.softmax(-1).argmax(-1).cpu().numpy().astype(np.int64)
                _draw_2d_boxes_on_image(
                    im_pred_2d,
                    pred_bboxes[display_mask],
                    pred_cls_np[display_mask],
                    names=validator.names,
                    confs=pred_conf_np[display_mask],
                    thickness=line_thick,
                    diff_cls=pred_diff_np[display_mask] if pred_diff_np is not None else None,
                )

                if preds_3d_sel is not None and anchors_sel is not None and strides_sel is not None:
                    p3d = preds_3d_sel[batch_sample_idx].cpu().numpy()
                    pedge = preds_edge_sel[batch_sample_idx].cpu().numpy() if preds_edge_sel is not None else None
                    anchors_np = anchors_sel[batch_sample_idx].cpu().numpy()
                    strides_np = strides_sel[batch_sample_idx].cpu().numpy()
                    for i in np.where(display_mask)[0]:
                        d = decode_3d_prediction(
                            p3d[i],
                            anchors_np[:, i],
                            float(strides_np[i]),
                            calib,
                            img_w,
                            img_h,
                            face_3d_classes,
                            complete_3d_classes,
                            int(pred_cls_np[i]),
                            pred_edge_60=pedge[i] if pedge is not None else None,
                            bbox_xyxy=pred_bboxes[i],
                        )
                        if d is not None and d.get("corners_3d") is not None:
                            draw_3d_box(
                                im_pred_3d,
                                d["corners_3d"],
                                calib,
                                d.get("face_center_2d"),
                                d.get("face_color"),
                                edge_points_2d=d.get("edge_points_2d"),
                                edge_color=(0, 165, 255),
                                thickness=line_thick,
                            )

        # --- Add labels to each quadrant ---
        font = cv2.FONT_HERSHEY_SIMPLEX
        for im_q, label in [(im_gt_2d, "Target 2D"), (im_gt_3d, "Target 3D"),
                            (im_pred_2d, "Pred 2D"), (im_pred_3d, "Pred 3D")]:
            cv2.putText(im_q, label, (10, 40), font, 1.0, (0, 255, 255), 1, cv2.LINE_AA)

        # --- Assemble 2x2 grid ---
        top_row = np.hstack([im_gt_2d, im_gt_3d])
        bot_row = np.hstack([im_pred_2d, im_pred_3d])
        grid = np.vstack([top_row, bot_row])

        header = f"{image_name or 'unknown'} | idx={global_sample_idx:04d} | {rank_tag} | mode={camera_mode}"
        cv2.rectangle(grid, (0, 0), (grid.shape[1], 30), (32, 32, 32), -1)
        cv2.putText(grid, header[:200], (10, 21), font, 0.55, (255, 255, 255), 1, cv2.LINE_AA)

        # Convert BGR -> RGB for TensorBoard
        grid_rgb = cv2.cvtColor(grid, cv2.COLOR_BGR2RGB)

        WRITER.add_image(
            f"val/{rank_tag}/{camera_mode}/sample{global_sample_idx:04d}_{image_tag}_2d3d",
            grid_rgb,
            dataformats="HWC",
            global_step=epoch,
        )

    except Exception as e:
        LOGGER.warning(f"{PREFIX}Failed to log 3D visualization: {e}")


def on_val_end(validator) -> None:
    """Clean up after validation."""
    _reset_val_logging_state()


def on_train_end(trainer) -> None:
    """Flush and close the TensorBoard writer at the end of training."""
    _reset_val_logging_state()
    _close_writer()


callbacks = (
    {
        "on_pretrain_routine_start": on_pretrain_routine_start,
        "on_train_start": on_train_start,
        "on_fit_epoch_end": on_fit_epoch_end,
        "on_train_epoch_end": on_train_epoch_end,
        "on_val_start": on_val_start,
        "on_val_batch_end": on_val_batch_end,
        "on_val_end": on_val_end,
        "on_train_end": on_train_end,
    }
    if SummaryWriter
    else {}
)
