Compare commits
4
Commits
ff83899739
...
b66da33b2d
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
b66da33b2d | ||
|
|
e89b8f3c6a | ||
|
|
80786d7281 | ||
|
|
d0ae29280c |
@@ -3,6 +3,7 @@ from __future__ import annotations
|
||||
import base64
|
||||
import json
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import time
|
||||
from collections import OrderedDict
|
||||
@@ -17,8 +18,37 @@ import numpy as np
|
||||
|
||||
from .schemas import Resource
|
||||
|
||||
_DLL_DIRECTORY_HANDLES: list[Any] = []
|
||||
|
||||
|
||||
def _configure_windows_cuda_dll_path() -> None:
|
||||
if os.name != "nt" or not hasattr(os, "add_dll_directory"):
|
||||
return
|
||||
seen: set[str] = set()
|
||||
for entry in sys.path:
|
||||
if not entry:
|
||||
continue
|
||||
nvidia_root = Path(entry) / "nvidia"
|
||||
if not nvidia_root.is_dir():
|
||||
continue
|
||||
for dll_dir in nvidia_root.glob("*/bin"):
|
||||
key = str(dll_dir.resolve())
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
try:
|
||||
_DLL_DIRECTORY_HANDLES.append(os.add_dll_directory(key))
|
||||
os.environ["PATH"] = key + os.pathsep + os.environ.get("PATH", "")
|
||||
except OSError:
|
||||
continue
|
||||
|
||||
|
||||
_configure_windows_cuda_dll_path()
|
||||
|
||||
try:
|
||||
import onnxruntime as ort
|
||||
if hasattr(ort, "preload_dlls"):
|
||||
ort.preload_dlls()
|
||||
except ImportError: # pragma: no cover - exercised by the lightweight source test environment
|
||||
ort = None
|
||||
|
||||
@@ -28,6 +58,44 @@ except ImportError: # pragma: no cover - exercised by the lightweight source te
|
||||
Minio = None
|
||||
|
||||
|
||||
def _repo_root() -> Path | None:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / "infra" / "model-registry").is_dir() and (parent / "runtime").is_dir():
|
||||
return parent
|
||||
return None
|
||||
|
||||
|
||||
def _default_registry_path() -> Path:
|
||||
configured = os.getenv("RAIL_MODEL_REGISTRY")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
container_path = Path("/app/config/model-registry.json")
|
||||
if container_path.is_file():
|
||||
return container_path
|
||||
root = _repo_root()
|
||||
if root:
|
||||
local_path = root / "infra" / "model-registry" / "cpu-models.json"
|
||||
if local_path.is_file():
|
||||
return local_path
|
||||
return container_path
|
||||
|
||||
|
||||
def _default_model_dir() -> Path:
|
||||
configured = os.getenv("RAIL_MODEL_DIR")
|
||||
if configured:
|
||||
return Path(configured)
|
||||
container_path = Path("/models")
|
||||
if container_path.exists():
|
||||
return container_path
|
||||
root = _repo_root()
|
||||
if root:
|
||||
local_path = root / "runtime" / "models"
|
||||
if local_path.is_dir():
|
||||
return local_path
|
||||
return container_path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CpuRuntimeSettings:
|
||||
profile: str
|
||||
@@ -45,13 +113,13 @@ class CpuRuntimeSettings:
|
||||
def from_env(cls) -> "CpuRuntimeSettings":
|
||||
return cls(
|
||||
profile=os.getenv("RAIL_RUNTIME_PROFILE", "cpu-local"),
|
||||
registry_path=Path(os.getenv("RAIL_MODEL_REGISTRY", "/app/config/model-registry.json")),
|
||||
model_dir=Path(os.getenv("RAIL_MODEL_DIR", "/models")),
|
||||
registry_path=_default_registry_path(),
|
||||
model_dir=_default_model_dir(),
|
||||
execution_provider=os.getenv("RAIL_EXECUTION_PROVIDER", "CPUExecutionProvider"),
|
||||
intra_op_threads=max(1, int(os.getenv("RAIL_INTRA_OP_THREADS", "4"))),
|
||||
inter_op_threads=max(1, int(os.getenv("RAIL_INTER_OP_THREADS", "1"))),
|
||||
max_concurrency=max(1, int(os.getenv("RAIL_MAX_CONCURRENCY", "1"))),
|
||||
max_loaded_models=max(1, int(os.getenv("RAIL_MAX_LOADED_MODELS", "1"))),
|
||||
max_loaded_models=max(1, int(os.getenv("RAIL_MAX_LOADED_MODELS", "2"))),
|
||||
max_resource_bytes=max(1, int(os.getenv("RAIL_MAX_RESOURCE_MB", "64"))) * 1024 * 1024,
|
||||
fallback_mode=os.getenv("RAIL_FALLBACK_MODE", "baseline"),
|
||||
)
|
||||
@@ -154,6 +222,17 @@ class ModelRegistry:
|
||||
"parser": "paddle-detection",
|
||||
"input_size": 640,
|
||||
},
|
||||
{
|
||||
"model_group": "vision-detector",
|
||||
"model_version": "traffic-yolov8n-coco",
|
||||
"display_name": "YOLOv8n COCO 交通演示本地 ONNX",
|
||||
"family": "YOLOv8",
|
||||
"artifact": "vision-detector/yolov8n-coco/model.onnx",
|
||||
"labels": "vision-detector/yolov8n-coco/labels.txt",
|
||||
"parser": "ultralytics-yolo",
|
||||
"input_size": 640,
|
||||
"active": False,
|
||||
},
|
||||
{
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "cpu-v1.0.0",
|
||||
@@ -166,6 +245,17 @@ class ModelRegistry:
|
||||
"mean": [0.5, 0.5, 0.5],
|
||||
"std": [0.5, 0.5, 0.5],
|
||||
},
|
||||
{
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "traffic-yolov8n-seg-coco",
|
||||
"display_name": "YOLOv8n-seg COCO 交通实例分割本地 ONNX",
|
||||
"family": "YOLOv8-seg",
|
||||
"artifact": "vision-segmenter/yolov8n-seg-coco/model.onnx",
|
||||
"labels": "vision-segmenter/yolov8n-seg-coco/labels.txt",
|
||||
"parser": "ultralytics-yolo-seg",
|
||||
"input_size": 640,
|
||||
"active": False,
|
||||
},
|
||||
{
|
||||
"model_group": "thermal-analyzer",
|
||||
"model_version": "cpu-v1.0.0",
|
||||
@@ -419,6 +509,10 @@ class LazyOnnxRuntime:
|
||||
) -> list[dict[str, Any]]:
|
||||
if spec.parser == "semantic-segmentation":
|
||||
return self._parse_segmentation(outputs, spec, parameters)
|
||||
if spec.parser == "ultralytics-yolo-seg":
|
||||
return self._parse_ultralytics_yolo_seg(outputs, spec, transform, parameters)
|
||||
if spec.parser == "ultralytics-yolo":
|
||||
return self._parse_ultralytics_yolo(outputs, spec, transform, parameters)
|
||||
return self._parse_detection(outputs, spec, transform, parameters)
|
||||
|
||||
def _parse_detection(
|
||||
@@ -475,6 +569,289 @@ class LazyOnnxRuntime:
|
||||
break
|
||||
return results
|
||||
|
||||
def _parse_ultralytics_yolo(
|
||||
self,
|
||||
outputs: list[np.ndarray],
|
||||
spec: ModelSpec,
|
||||
transform: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
threshold = float(parameters.get("confidence_threshold", 0.35))
|
||||
nms_threshold = float(parameters.get("nms_iou_threshold", 0.45))
|
||||
max_detections = int(parameters.get("max_detections", 100))
|
||||
allowed = {str(item).strip().lower() for item in parameters.get("allowed_categories", []) if str(item).strip()}
|
||||
labels = self._labels(spec)
|
||||
candidates = self._yolo_output_matrix(outputs)
|
||||
|
||||
scored: list[dict[str, Any]] = []
|
||||
for row in candidates:
|
||||
if row.shape[0] < 5:
|
||||
continue
|
||||
|
||||
cx, cy, width, height = (float(item) for item in row[:4])
|
||||
if labels and row.shape[0] == len(labels) + 5:
|
||||
objectness = float(row[4])
|
||||
class_scores = row[5:]
|
||||
class_id = int(np.argmax(class_scores))
|
||||
score = objectness * float(class_scores[class_id])
|
||||
else:
|
||||
class_scores = row[4:]
|
||||
class_id = int(np.argmax(class_scores))
|
||||
score = float(class_scores[class_id])
|
||||
|
||||
if score < threshold:
|
||||
continue
|
||||
|
||||
category = labels[class_id] if 0 <= class_id < len(labels) else f"class-{class_id}"
|
||||
if allowed and category.lower() not in allowed:
|
||||
continue
|
||||
|
||||
scale = 1.0 if max(abs(cx), abs(cy), abs(width), abs(height)) <= 2.0 else None
|
||||
if scale is None:
|
||||
x1 = (cx - width / 2) / transform["input_w"]
|
||||
y1 = (cy - height / 2) / transform["input_h"]
|
||||
x2 = (cx + width / 2) / transform["input_w"]
|
||||
y2 = (cy + height / 2) / transform["input_h"]
|
||||
else:
|
||||
x1 = cx - width / 2
|
||||
y1 = cy - height / 2
|
||||
x2 = cx + width / 2
|
||||
y2 = cy + height / 2
|
||||
|
||||
bbox = [self._clip(x1), self._clip(y1), self._clip(x2), self._clip(y2)]
|
||||
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||||
continue
|
||||
|
||||
scored.append(
|
||||
{
|
||||
"category": category,
|
||||
"class_id": class_id,
|
||||
"confidence": round(float(score), 4),
|
||||
"bbox": bbox,
|
||||
}
|
||||
)
|
||||
|
||||
scored.sort(key=lambda item: float(item["confidence"]), reverse=True)
|
||||
kept: list[dict[str, Any]] = []
|
||||
for item in scored:
|
||||
if any(item["class_id"] == kept_item["class_id"] and self._box_iou(item["bbox"], kept_item["bbox"]) > nms_threshold for kept_item in kept):
|
||||
continue
|
||||
kept.append(item)
|
||||
if len(kept) >= max_detections:
|
||||
break
|
||||
|
||||
return [
|
||||
{
|
||||
"category": str(item["category"]),
|
||||
"confidence": float(item["confidence"]),
|
||||
"geometry": {"type": "BBox", "coordinates": item["bbox"], "coordinate_space": "normalized"},
|
||||
"measurements": {},
|
||||
}
|
||||
for item in kept
|
||||
]
|
||||
|
||||
def _parse_ultralytics_yolo_seg(
|
||||
self,
|
||||
outputs: list[np.ndarray],
|
||||
spec: ModelSpec,
|
||||
transform: dict[str, Any],
|
||||
parameters: dict[str, Any],
|
||||
) -> list[dict[str, Any]]:
|
||||
threshold = float(parameters.get("mask_threshold", parameters.get("confidence_threshold", 0.35)))
|
||||
nms_threshold = float(parameters.get("nms_iou_threshold", 0.45))
|
||||
max_detections = int(parameters.get("max_detections", 40))
|
||||
mask_binary_threshold = float(parameters.get("mask_binary_threshold", 0.5))
|
||||
minimum_area = int(parameters.get("minimum_area", 64))
|
||||
allowed = {str(item).strip().lower() for item in parameters.get("allowed_categories", []) if str(item).strip()}
|
||||
labels = self._labels(spec)
|
||||
detections, prototypes = self._yolo_seg_candidates(outputs, labels, threshold, allowed, transform)
|
||||
|
||||
detections.sort(key=lambda item: float(item["confidence"]), reverse=True)
|
||||
kept: list[dict[str, Any]] = []
|
||||
for item in detections:
|
||||
if any(item["class_id"] == kept_item["class_id"] and self._box_iou(item["bbox"], kept_item["bbox"]) > nms_threshold for kept_item in kept):
|
||||
continue
|
||||
kept.append(item)
|
||||
if len(kept) >= max_detections:
|
||||
break
|
||||
|
||||
if prototypes is None:
|
||||
return []
|
||||
if prototypes.ndim == 4:
|
||||
prototypes = prototypes[0]
|
||||
if prototypes.ndim != 3:
|
||||
return []
|
||||
|
||||
proto_channels, proto_h, proto_w = prototypes.shape
|
||||
results: list[dict[str, Any]] = []
|
||||
contour_simplification = float(parameters.get("contour_simplification", 1.4))
|
||||
for item in kept:
|
||||
coefficients = np.asarray(item["mask_coefficients"], dtype=np.float32)
|
||||
if coefficients.shape[0] != proto_channels:
|
||||
continue
|
||||
mask_logits = np.tensordot(coefficients, prototypes, axes=(0, 0))
|
||||
mask = 1.0 / (1.0 + np.exp(-mask_logits))
|
||||
x1, y1, x2, y2 = item["bbox"]
|
||||
left = max(0, min(proto_w - 1, int(np.floor(x1 * proto_w))))
|
||||
top = max(0, min(proto_h - 1, int(np.floor(y1 * proto_h))))
|
||||
right = max(left + 1, min(proto_w, int(np.ceil(x2 * proto_w))))
|
||||
bottom = max(top + 1, min(proto_h, int(np.ceil(y2 * proto_h))))
|
||||
cropped = np.zeros_like(mask, dtype=np.uint8)
|
||||
cropped[top:bottom, left:right] = (mask[top:bottom, left:right] >= mask_binary_threshold).astype(np.uint8) * 255
|
||||
mask_area = int(np.count_nonzero(cropped))
|
||||
if mask_area < minimum_area:
|
||||
continue
|
||||
contours, _ = cv2.findContours(cropped, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
points: list[list[float]] = []
|
||||
if contours:
|
||||
contour = max(contours, key=cv2.contourArea)
|
||||
epsilon = max(0.5, cv2.arcLength(contour, True) * 0.006 + contour_simplification)
|
||||
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||
points = [
|
||||
[self._clip(float(point[0][0]) / proto_w), self._clip(float(point[0][1]) / proto_h)]
|
||||
for point in simplified
|
||||
]
|
||||
if len(points) >= 3:
|
||||
points.append(points[0])
|
||||
else:
|
||||
points = []
|
||||
results.append(
|
||||
{
|
||||
"category": str(item["category"]),
|
||||
"confidence": round(float(item["confidence"]), 4),
|
||||
"geometry": {"type": "Polygon", "coordinates": [points], "coordinate_space": "normalized"},
|
||||
"measurements": {"area_ratio": round(mask_area / float(proto_h * proto_w), 6)},
|
||||
"mask": self._encode_binary_mask(cropped),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def _yolo_seg_candidates(
|
||||
self,
|
||||
outputs: list[np.ndarray],
|
||||
labels: list[str],
|
||||
threshold: float,
|
||||
allowed: set[str],
|
||||
transform: dict[str, Any],
|
||||
) -> tuple[list[dict[str, Any]], np.ndarray | None]:
|
||||
feature_groups: dict[tuple[int, int], dict[str, np.ndarray]] = {}
|
||||
prototypes: np.ndarray | None = None
|
||||
class_count = len(labels) or 80
|
||||
mask_channels = 32
|
||||
|
||||
for output in outputs:
|
||||
array = np.asarray(output)
|
||||
if array.ndim == 4 and array.shape[0] == 1:
|
||||
array = array[0]
|
||||
if array.ndim != 3:
|
||||
continue
|
||||
if array.shape[0] == mask_channels and array.shape[1] >= 80 and array.shape[2] >= 80:
|
||||
prototypes = array.astype(np.float32, copy=False)
|
||||
continue
|
||||
if array.shape[-1] in {64, class_count, mask_channels}:
|
||||
height, width, channels = array.shape
|
||||
entry = feature_groups.setdefault((height, width), {})
|
||||
if channels == 64:
|
||||
entry["boxes"] = array.astype(np.float32, copy=False)
|
||||
elif channels == class_count:
|
||||
entry["classes"] = array.astype(np.float32, copy=False)
|
||||
elif channels == mask_channels:
|
||||
entry["masks"] = array.astype(np.float32, copy=False)
|
||||
|
||||
candidates: list[dict[str, Any]] = []
|
||||
for (height, width), group in feature_groups.items():
|
||||
if not {"boxes", "classes", "masks"}.issubset(group):
|
||||
continue
|
||||
stride_x = transform["input_w"] / width
|
||||
stride_y = transform["input_h"] / height
|
||||
boxes = self._decode_yolo_dfl(group["boxes"], stride_x, stride_y)
|
||||
class_scores = self._sigmoid(group["classes"].reshape((-1, class_count)))
|
||||
mask_coefficients = group["masks"].reshape((-1, mask_channels))
|
||||
best_ids = np.argmax(class_scores, axis=1)
|
||||
best_scores = class_scores[np.arange(class_scores.shape[0]), best_ids]
|
||||
selected_indices = np.where(best_scores >= threshold)[0]
|
||||
|
||||
for index in selected_indices:
|
||||
class_id = int(best_ids[index])
|
||||
category = labels[class_id] if 0 <= class_id < len(labels) else f"class-{class_id}"
|
||||
if allowed and category.lower() not in allowed:
|
||||
continue
|
||||
x1, y1, x2, y2 = boxes[index]
|
||||
bbox = [
|
||||
self._clip(float(x1) / transform["input_w"]),
|
||||
self._clip(float(y1) / transform["input_h"]),
|
||||
self._clip(float(x2) / transform["input_w"]),
|
||||
self._clip(float(y2) / transform["input_h"]),
|
||||
]
|
||||
if bbox[2] <= bbox[0] or bbox[3] <= bbox[1]:
|
||||
continue
|
||||
candidates.append(
|
||||
{
|
||||
"category": category,
|
||||
"class_id": class_id,
|
||||
"confidence": float(best_scores[index]),
|
||||
"bbox": bbox,
|
||||
"mask_coefficients": mask_coefficients[index],
|
||||
}
|
||||
)
|
||||
return candidates, prototypes
|
||||
|
||||
def _decode_yolo_dfl(self, boxes: np.ndarray, stride_x: float, stride_y: float) -> np.ndarray:
|
||||
height, width, _ = boxes.shape
|
||||
reg_max = boxes.shape[-1] // 4
|
||||
distribution = boxes.reshape((height, width, 4, reg_max))
|
||||
distribution = self._softmax(distribution, axis=-1)
|
||||
projection = np.arange(reg_max, dtype=np.float32)
|
||||
distances = np.sum(distribution * projection, axis=-1)
|
||||
grid_y, grid_x = np.meshgrid(np.arange(height, dtype=np.float32), np.arange(width, dtype=np.float32), indexing="ij")
|
||||
center_x = (grid_x + 0.5) * stride_x
|
||||
center_y = (grid_y + 0.5) * stride_y
|
||||
left = distances[..., 0] * stride_x
|
||||
top = distances[..., 1] * stride_y
|
||||
right = distances[..., 2] * stride_x
|
||||
bottom = distances[..., 3] * stride_y
|
||||
decoded = np.stack([center_x - left, center_y - top, center_x + right, center_y + bottom], axis=-1)
|
||||
return decoded.reshape((-1, 4))
|
||||
|
||||
def _yolo_output_matrix(self, outputs: list[np.ndarray]) -> np.ndarray:
|
||||
best: np.ndarray | None = None
|
||||
for output in outputs:
|
||||
array = np.squeeze(np.asarray(output))
|
||||
if array.ndim != 2:
|
||||
continue
|
||||
if array.shape[0] < array.shape[1] and array.shape[0] <= 512:
|
||||
array = array.T
|
||||
if array.shape[-1] < 5:
|
||||
continue
|
||||
if best is None or array.shape[0] > best.shape[0]:
|
||||
best = array.astype(np.float32, copy=False)
|
||||
if best is None:
|
||||
raise ValueError("模型输出中没有可识别的 YOLO 检测矩阵")
|
||||
return best
|
||||
|
||||
@staticmethod
|
||||
def _box_iou(left: list[float], right: list[float]) -> float:
|
||||
x1 = max(left[0], right[0])
|
||||
y1 = max(left[1], right[1])
|
||||
x2 = min(left[2], right[2])
|
||||
y2 = min(left[3], right[3])
|
||||
intersection = max(0.0, x2 - x1) * max(0.0, y2 - y1)
|
||||
if intersection <= 0:
|
||||
return 0.0
|
||||
left_area = max(0.0, left[2] - left[0]) * max(0.0, left[3] - left[1])
|
||||
right_area = max(0.0, right[2] - right[0]) * max(0.0, right[3] - right[1])
|
||||
return intersection / max(1e-9, left_area + right_area - intersection)
|
||||
|
||||
@staticmethod
|
||||
def _sigmoid(array: np.ndarray) -> np.ndarray:
|
||||
return 1.0 / (1.0 + np.exp(-array))
|
||||
|
||||
@staticmethod
|
||||
def _softmax(array: np.ndarray, axis: int) -> np.ndarray:
|
||||
shifted = array - np.max(array, axis=axis, keepdims=True)
|
||||
exp = np.exp(shifted)
|
||||
return exp / np.sum(exp, axis=axis, keepdims=True)
|
||||
|
||||
def _parse_segmentation(
|
||||
self, outputs: list[np.ndarray], spec: ModelSpec, parameters: dict[str, Any]
|
||||
) -> list[dict[str, Any]]:
|
||||
@@ -495,20 +872,26 @@ class LazyOnnxRuntime:
|
||||
results: list[dict[str, Any]] = []
|
||||
for class_id in (int(item) for item in np.unique(mask) if int(item) != 0):
|
||||
binary = np.where(mask == class_id, 255, 0).astype(np.uint8)
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
|
||||
area = cv2.contourArea(contour)
|
||||
component_count, component_labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
|
||||
for component_id in range(1, component_count):
|
||||
area = int(stats[component_id, cv2.CC_STAT_AREA])
|
||||
if area < minimum_area:
|
||||
continue
|
||||
component_mask = np.where(component_labels == component_id, 255, 0).astype(np.uint8)
|
||||
contours, _ = cv2.findContours(component_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if not contours:
|
||||
continue
|
||||
contour = max(contours, key=cv2.contourArea)
|
||||
epsilon = float(parameters.get("contour_simplification", 0.8))
|
||||
simplified = cv2.approxPolyDP(contour, max(0.5, epsilon), True)
|
||||
points = [
|
||||
[self._clip(float(point[0][0]) / mask.shape[1]), self._clip(float(point[0][1]) / mask.shape[0])]
|
||||
for point in simplified
|
||||
]
|
||||
if len(points) < 3:
|
||||
continue
|
||||
points.append(points[0])
|
||||
if len(points) >= 3:
|
||||
points.append(points[0])
|
||||
else:
|
||||
points = []
|
||||
category = labels[class_id] if class_id < len(labels) else f"class-{class_id}"
|
||||
results.append(
|
||||
{
|
||||
@@ -516,10 +899,34 @@ class LazyOnnxRuntime:
|
||||
"confidence": 1.0,
|
||||
"geometry": {"type": "Polygon", "coordinates": [points], "coordinate_space": "normalized"},
|
||||
"measurements": {"area_ratio": round(area / mask.size, 6)},
|
||||
"mask": self._encode_binary_mask(component_mask),
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _encode_binary_mask(mask: np.ndarray) -> dict[str, Any]:
|
||||
binary = (mask > 0).astype(np.uint8, copy=False)
|
||||
flat = binary.reshape(-1)
|
||||
counts: list[int] = []
|
||||
current = 0
|
||||
run_length = 0
|
||||
for value in flat:
|
||||
item = int(value)
|
||||
if item == current:
|
||||
run_length += 1
|
||||
continue
|
||||
counts.append(run_length)
|
||||
run_length = 1
|
||||
current = item
|
||||
counts.append(run_length)
|
||||
return {
|
||||
"encoding": "rle",
|
||||
"width": int(binary.shape[1]),
|
||||
"height": int(binary.shape[0]),
|
||||
"counts": counts,
|
||||
}
|
||||
|
||||
def _labels(self, spec: ModelSpec) -> list[str]:
|
||||
path = spec.labels_path(self.settings.model_dir)
|
||||
if path and path.is_file():
|
||||
@@ -581,6 +988,19 @@ class CpuVisionRuntime:
|
||||
)
|
||||
return self.models.predict(model_group, image, parameters, model_version)
|
||||
|
||||
def infer_image_array(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
model_group: str,
|
||||
parameters: dict[str, Any],
|
||||
model_version: str | None = None,
|
||||
) -> RuntimeOutcome:
|
||||
if image.ndim == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
if image.ndim == 3 and image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
return self.models.predict(model_group, image, parameters, model_version)
|
||||
|
||||
def status(self) -> dict[str, Any]:
|
||||
return self.models.status()
|
||||
|
||||
|
||||
@@ -0,0 +1,337 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
from typing import Any
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
|
||||
from .cpu_runtime import CpuVisionRuntime, RuntimeOutcome
|
||||
|
||||
|
||||
TRAFFIC_ALLOWED_CATEGORIES = [
|
||||
"person",
|
||||
"bicycle",
|
||||
"car",
|
||||
"motorcycle",
|
||||
"bus",
|
||||
"train",
|
||||
"truck",
|
||||
"traffic light",
|
||||
"stop sign",
|
||||
]
|
||||
|
||||
DETECTION_SCENES: dict[str, dict[str, Any]] = {
|
||||
"inspection": {
|
||||
"id": "inspection",
|
||||
"label": "铁路/无人机巡检",
|
||||
"description": "使用项目默认可见光检测模型,适合铁路巡检、小目标异物等场景。",
|
||||
"detection_model_version": None,
|
||||
"segmentation_model_version": None,
|
||||
"allowed_categories": [],
|
||||
"default_confidence_threshold": 0.45,
|
||||
},
|
||||
"traffic-driving": {
|
||||
"id": "traffic-driving",
|
||||
"label": "驾车/道路交通",
|
||||
"description": "使用 YOLOv8n/YOLOv8n-seg COCO 交通演示模型,过滤车辆、行人、信号灯等类别。",
|
||||
"detection_model_version": "traffic-yolov8n-coco",
|
||||
"segmentation_model_version": "traffic-yolov8n-seg-coco",
|
||||
"allowed_categories": TRAFFIC_ALLOWED_CATEGORIES,
|
||||
"default_confidence_threshold": 0.35,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
class FrameInferenceService:
|
||||
def __init__(self, runtime: CpuVisionRuntime):
|
||||
self.runtime = runtime
|
||||
|
||||
@staticmethod
|
||||
def detection_scenes() -> list[dict[str, Any]]:
|
||||
return list(DETECTION_SCENES.values())
|
||||
|
||||
def decode_image(self, payload: bytes) -> np.ndarray:
|
||||
image = cv2.imdecode(np.frombuffer(payload, dtype=np.uint8), cv2.IMREAD_UNCHANGED)
|
||||
if image is None:
|
||||
raise ValueError("上传帧不是可解码图像")
|
||||
if image.ndim == 2:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_GRAY2BGR)
|
||||
if image.ndim == 3 and image.shape[2] == 4:
|
||||
image = cv2.cvtColor(image, cv2.COLOR_BGRA2BGR)
|
||||
return image
|
||||
|
||||
def resize_for_inference(self, image: np.ndarray, max_width: int | None) -> np.ndarray:
|
||||
if not max_width or max_width <= 0 or image.shape[1] <= max_width:
|
||||
return image
|
||||
scale = max_width / image.shape[1]
|
||||
target = (max_width, max(1, int(round(image.shape[0] * scale))))
|
||||
return cv2.resize(image, target, interpolation=cv2.INTER_AREA)
|
||||
|
||||
def infer_frame(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
*,
|
||||
detect_enabled: bool,
|
||||
segment_enabled: bool,
|
||||
confidence_threshold: float,
|
||||
mask_threshold: float,
|
||||
max_detections: int,
|
||||
max_inference_width: int | None = None,
|
||||
detection_scene: str | None = None,
|
||||
detection_model_version: str | None = None,
|
||||
segmentation_model_version: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
started = time.perf_counter()
|
||||
inference_image = self.resize_for_inference(image, max_inference_width)
|
||||
detections: list[dict[str, Any]] = []
|
||||
segments: list[dict[str, Any]] = []
|
||||
warnings: list[dict[str, Any]] = []
|
||||
runtimes: dict[str, Any] = {}
|
||||
scene = self._detection_scene(detection_scene)
|
||||
selected_detection_version = detection_model_version or scene.get("detection_model_version")
|
||||
selected_segmentation_version = segmentation_model_version or scene.get("segmentation_model_version")
|
||||
|
||||
if detect_enabled:
|
||||
detection_parameters: dict[str, Any] = {
|
||||
"confidence_threshold": confidence_threshold,
|
||||
"max_detections": max_detections,
|
||||
}
|
||||
if scene.get("allowed_categories"):
|
||||
detection_parameters["allowed_categories"] = scene["allowed_categories"]
|
||||
outcome = self.runtime.infer_image_array(
|
||||
inference_image,
|
||||
"vision-detector",
|
||||
detection_parameters,
|
||||
selected_detection_version,
|
||||
)
|
||||
detections = self._detections(outcome)
|
||||
runtimes["detection_latency_ms"] = outcome.latency_ms
|
||||
if outcome.reason and not detections:
|
||||
detections = self._demo_detections(inference_image, confidence_threshold, max_detections)
|
||||
warnings.extend(self._fallback_warning("vision-detector", outcome.reason))
|
||||
else:
|
||||
warnings.extend(self._warnings("vision-detector", outcome))
|
||||
|
||||
if segment_enabled and scene["id"] == "traffic-driving" and not selected_segmentation_version:
|
||||
warnings.append(
|
||||
{
|
||||
"code": "TRAFFIC_SEGMENTER_NOT_INSTALLED",
|
||||
"message": "驾车场景当前仅安装交通目标检测 ONNX;未安装道路/车道线分割 ONNX,已跳过分割以避免误标。",
|
||||
"model_group": "vision-segmenter",
|
||||
}
|
||||
)
|
||||
elif segment_enabled:
|
||||
outcome = self.runtime.infer_image_array(
|
||||
inference_image,
|
||||
"vision-segmenter",
|
||||
{"mask_threshold": mask_threshold, "minimum_area": 64, "contour_simplification": 0.8},
|
||||
selected_segmentation_version,
|
||||
)
|
||||
segments = self._segments(outcome)
|
||||
runtimes["segmentation_latency_ms"] = outcome.latency_ms
|
||||
if outcome.reason and not segments:
|
||||
segments = self._demo_segments(inference_image, mask_threshold)
|
||||
warnings.extend(self._fallback_warning("vision-segmenter", outcome.reason))
|
||||
else:
|
||||
warnings.extend(self._warnings("vision-segmenter", outcome))
|
||||
|
||||
return {
|
||||
"source": {"width": int(image.shape[1]), "height": int(image.shape[0])},
|
||||
"inference": {"width": int(inference_image.shape[1]), "height": int(inference_image.shape[0])},
|
||||
"runtime": {
|
||||
**runtimes,
|
||||
"total_latency_ms": round((time.perf_counter() - started) * 1000, 2),
|
||||
"provider": self.runtime.settings.execution_provider,
|
||||
},
|
||||
"scene": {
|
||||
"id": scene["id"],
|
||||
"label": scene["label"],
|
||||
"detection_model_version": selected_detection_version,
|
||||
"segmentation_model_version": selected_segmentation_version,
|
||||
},
|
||||
"results": {"detections": detections, "segments": segments},
|
||||
"warnings": warnings,
|
||||
}
|
||||
|
||||
def _detection_scene(self, detection_scene: str | None) -> dict[str, Any]:
|
||||
if detection_scene and detection_scene in DETECTION_SCENES:
|
||||
return DETECTION_SCENES[detection_scene]
|
||||
return DETECTION_SCENES["inspection"]
|
||||
|
||||
def _detections(self, outcome: RuntimeOutcome) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in outcome.results:
|
||||
geometry = dict(item.get("geometry", {}))
|
||||
if geometry.get("type") != "BBox":
|
||||
continue
|
||||
results.append(
|
||||
{
|
||||
"category": str(item.get("category", "target")),
|
||||
"confidence": float(item.get("confidence", 0)),
|
||||
"bbox": list(geometry.get("coordinates", [])),
|
||||
"model_group": outcome.model.get("model_group", "vision-detector"),
|
||||
"model_version": outcome.model.get("model_version"),
|
||||
"execution_mode": outcome.execution_mode,
|
||||
}
|
||||
)
|
||||
return results
|
||||
|
||||
def _segments(self, outcome: RuntimeOutcome) -> list[dict[str, Any]]:
|
||||
results: list[dict[str, Any]] = []
|
||||
for item in outcome.results:
|
||||
geometry = dict(item.get("geometry", {}))
|
||||
if geometry.get("type") != "Polygon":
|
||||
continue
|
||||
coordinates = geometry.get("coordinates") or []
|
||||
polygon = coordinates[0] if coordinates else []
|
||||
segment = {
|
||||
"category": str(item.get("category", "segment")),
|
||||
"confidence": float(item.get("confidence", 1.0)),
|
||||
"polygon": polygon,
|
||||
"area_ratio": dict(item.get("measurements", {})).get("area_ratio"),
|
||||
"model_group": outcome.model.get("model_group", "vision-segmenter"),
|
||||
"model_version": outcome.model.get("model_version"),
|
||||
"execution_mode": outcome.execution_mode,
|
||||
}
|
||||
mask = item.get("mask")
|
||||
if isinstance(mask, dict):
|
||||
segment["mask"] = mask
|
||||
results.append(segment)
|
||||
return results
|
||||
|
||||
def _warnings(self, model_group: str, outcome: RuntimeOutcome) -> list[dict[str, Any]]:
|
||||
if outcome.reason:
|
||||
code = "MODEL_ARTIFACT_MISSING" if "未安装" in outcome.reason else "MODEL_INFERENCE_UNAVAILABLE"
|
||||
return [{"code": code, "message": outcome.reason, "model_group": model_group}]
|
||||
return []
|
||||
|
||||
def _fallback_warning(self, model_group: str, reason: str) -> list[dict[str, Any]]:
|
||||
label = "目标检测" if model_group == "vision-detector" else "图像分割"
|
||||
return [
|
||||
{
|
||||
"code": "DEMO_FALLBACK_ACTIVE",
|
||||
"message": f"真实 {label} ONNX 模型未安装,已启用 OpenCV 本地演示模式;安装 model.onnx 后会自动切换真实模型。",
|
||||
"model_group": model_group,
|
||||
"detail": reason,
|
||||
}
|
||||
]
|
||||
|
||||
def _demo_detections(self, image: np.ndarray, threshold: float, max_detections: int) -> list[dict[str, Any]]:
|
||||
gray = cv2.cvtColor(image, cv2.COLOR_BGR2GRAY)
|
||||
blurred = cv2.GaussianBlur(gray, (5, 5), 0)
|
||||
edges = cv2.Canny(blurred, 50, 140)
|
||||
kernel = np.ones((5, 5), dtype=np.uint8)
|
||||
mask = cv2.dilate(edges, kernel, iterations=2)
|
||||
contours, _ = cv2.findContours(mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
height, width = image.shape[:2]
|
||||
image_area = float(width * height)
|
||||
results: list[dict[str, Any]] = []
|
||||
|
||||
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
|
||||
area = cv2.contourArea(contour)
|
||||
if area < image_area * 0.004 or area > image_area * 0.55:
|
||||
continue
|
||||
x, y, box_w, box_h = cv2.boundingRect(contour)
|
||||
if box_w < 12 or box_h < 12:
|
||||
continue
|
||||
extent = min(1.0, area / max(1.0, box_w * box_h))
|
||||
score = round(max(threshold, min(0.92, 0.48 + extent * 0.32 + min(0.12, area / image_area))), 4)
|
||||
results.append(
|
||||
{
|
||||
"category": "visual-target",
|
||||
"confidence": score,
|
||||
"bbox": [round(x / width, 6), round(y / height, 6), round((x + box_w) / width, 6), round((y + box_h) / height, 6)],
|
||||
"model_group": "opencv-demo-detector",
|
||||
"model_version": "local-demo",
|
||||
"execution_mode": "opencv-demo",
|
||||
}
|
||||
)
|
||||
if len(results) >= max_detections:
|
||||
break
|
||||
|
||||
if results:
|
||||
return results
|
||||
|
||||
# Keep the demo visibly responsive on very smooth frames without pretending this is a trained detector.
|
||||
return [
|
||||
{
|
||||
"category": "frame-region",
|
||||
"confidence": round(max(threshold, 0.5), 4),
|
||||
"bbox": [0.32, 0.28, 0.68, 0.72],
|
||||
"model_group": "opencv-demo-detector",
|
||||
"model_version": "local-demo",
|
||||
"execution_mode": "opencv-demo",
|
||||
}
|
||||
]
|
||||
|
||||
def _demo_segments(self, image: np.ndarray, mask_threshold: float) -> list[dict[str, Any]]:
|
||||
height, width = image.shape[:2]
|
||||
max_side = 360
|
||||
scale = min(1.0, max_side / max(width, height))
|
||||
sample = cv2.resize(image, (max(1, int(width * scale)), max(1, int(height * scale))), interpolation=cv2.INTER_AREA)
|
||||
lab = cv2.cvtColor(sample, cv2.COLOR_BGR2LAB)
|
||||
pixels = lab.reshape((-1, 3)).astype(np.float32)
|
||||
criteria = (cv2.TERM_CRITERIA_EPS + cv2.TERM_CRITERIA_MAX_ITER, 12, 1.0)
|
||||
_, labels, centers = cv2.kmeans(pixels, 4, None, criteria, 2, cv2.KMEANS_PP_CENTERS)
|
||||
label_image = labels.reshape(sample.shape[:2])
|
||||
|
||||
results: list[dict[str, Any]] = []
|
||||
sample_area = float(sample.shape[0] * sample.shape[1])
|
||||
ranked_ids = sorted(range(len(centers)), key=lambda idx: float(centers[idx][1] + centers[idx][2]), reverse=True)
|
||||
for cluster_id in ranked_ids:
|
||||
binary = np.where(label_image == cluster_id, 255, 0).astype(np.uint8)
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_OPEN, np.ones((3, 3), dtype=np.uint8))
|
||||
binary = cv2.morphologyEx(binary, cv2.MORPH_CLOSE, np.ones((7, 7), dtype=np.uint8))
|
||||
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
for contour in sorted(contours, key=cv2.contourArea, reverse=True):
|
||||
area = cv2.contourArea(contour)
|
||||
if area < sample_area * 0.015 or area > sample_area * 0.75:
|
||||
continue
|
||||
component_mask = np.zeros_like(binary)
|
||||
cv2.drawContours(component_mask, [contour], -1, 255, -1)
|
||||
epsilon = max(2.0, cv2.arcLength(contour, True) * 0.012)
|
||||
simplified = cv2.approxPolyDP(contour, epsilon, True)
|
||||
if len(simplified) < 3:
|
||||
continue
|
||||
polygon = [
|
||||
[round(float(point[0][0]) / sample.shape[1], 6), round(float(point[0][1]) / sample.shape[0], 6)]
|
||||
for point in simplified
|
||||
]
|
||||
if len(polygon) >= 3:
|
||||
polygon.append(polygon[0])
|
||||
else:
|
||||
polygon = []
|
||||
results.append(
|
||||
{
|
||||
"category": "visual-region",
|
||||
"confidence": round(max(mask_threshold, 0.62), 4),
|
||||
"polygon": polygon,
|
||||
"area_ratio": round(area / sample_area, 6),
|
||||
"mask": self._encode_binary_mask(component_mask),
|
||||
"model_group": "opencv-demo-segmenter",
|
||||
"model_version": "local-demo",
|
||||
"execution_mode": "opencv-demo",
|
||||
}
|
||||
)
|
||||
if len(results) >= 4:
|
||||
return results
|
||||
return results
|
||||
|
||||
@staticmethod
|
||||
def _encode_binary_mask(mask: np.ndarray) -> dict[str, Any]:
|
||||
binary = (mask > 0).astype(np.uint8, copy=False)
|
||||
flat = binary.reshape(-1)
|
||||
counts: list[int] = []
|
||||
current = 0
|
||||
run_length = 0
|
||||
for value in flat:
|
||||
item = int(value)
|
||||
if item == current:
|
||||
run_length += 1
|
||||
continue
|
||||
counts.append(run_length)
|
||||
run_length = 1
|
||||
current = item
|
||||
counts.append(run_length)
|
||||
return {"encoding": "rle", "width": int(binary.shape[1]), "height": int(binary.shape[0]), "counts": counts}
|
||||
@@ -10,9 +10,14 @@ from .schemas import (
|
||||
ModelTestInferenceRequest,
|
||||
ModelTestInferenceResponse,
|
||||
)
|
||||
from .video_demo_routes import create_video_demo_router
|
||||
from .video_export_runtime import VideoExportManager
|
||||
|
||||
app = FastAPI(title="Rail UAV Vision Inference Service", version="0.1.0")
|
||||
engine = VisionInferenceEngine()
|
||||
video_export_manager = VideoExportManager(engine.runtime)
|
||||
app.state.video_export_manager = video_export_manager
|
||||
app.include_router(create_video_demo_router(engine, video_export_manager))
|
||||
|
||||
|
||||
@app.get("/health", response_model=HealthResponse)
|
||||
|
||||
@@ -0,0 +1,211 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import subprocess
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, BackgroundTasks, File, Form, HTTPException, UploadFile
|
||||
from fastapi.responses import FileResponse
|
||||
|
||||
from .engines import VisionInferenceEngine
|
||||
from .frame_runtime import FrameInferenceService
|
||||
from .video_demo_schemas import (
|
||||
VideoExportJobCreateResponse,
|
||||
VideoExportJobStatus,
|
||||
WarmupRequest,
|
||||
)
|
||||
from .video_export_runtime import (
|
||||
VIDEO_MIME_TYPES_BY_SUFFIX,
|
||||
VideoExportManager,
|
||||
VideoExportStateError,
|
||||
VideoUploadError,
|
||||
max_video_upload_bytes,
|
||||
)
|
||||
|
||||
|
||||
MAX_FRAME_UPLOAD_BYTES = 2 * 1024 * 1024
|
||||
|
||||
|
||||
def create_video_demo_router(
|
||||
engine: VisionInferenceEngine,
|
||||
export_manager: VideoExportManager | None = None,
|
||||
) -> APIRouter:
|
||||
router = APIRouter(prefix="/api/v1/video-demo", tags=["video-demo"])
|
||||
frame_service = FrameInferenceService(engine.runtime)
|
||||
export_manager = export_manager or VideoExportManager(engine.runtime)
|
||||
|
||||
@router.get("/capabilities")
|
||||
def capabilities() -> dict:
|
||||
runtime = engine.runtime_status()
|
||||
accelerated = str(runtime.get("execution_provider", "")).lower() in {"cudaexecutionprovider", "tensorrtexecutionprovider"}
|
||||
return {
|
||||
"runtime": runtime.get("runtime"),
|
||||
"execution_provider": runtime.get("execution_provider"),
|
||||
"execution_provider_ready": runtime.get("execution_provider_ready"),
|
||||
"accelerated": accelerated and bool(runtime.get("execution_provider_ready")),
|
||||
"runtime_available": runtime.get("runtime_available"),
|
||||
"available_providers": runtime.get("available_providers", []),
|
||||
"gpu": _gpu_info(),
|
||||
"models": runtime.get("models", []),
|
||||
"detection_scenes": frame_service.detection_scenes(),
|
||||
"recommended": {
|
||||
"max_inference_width": 960 if accelerated else 640,
|
||||
"detection_fps": 8 if accelerated else 2,
|
||||
"segmentation_fps": 3 if accelerated else 1,
|
||||
},
|
||||
"export": {
|
||||
"enabled": True,
|
||||
"output_root": str(export_manager.output_root),
|
||||
"max_video_upload_bytes": max_video_upload_bytes(),
|
||||
"allowed_extensions": sorted(VIDEO_MIME_TYPES_BY_SUFFIX),
|
||||
},
|
||||
}
|
||||
|
||||
@router.post("/warmup")
|
||||
def warmup(request: WarmupRequest) -> dict:
|
||||
loaded = []
|
||||
warnings = []
|
||||
for model_group in request.models:
|
||||
try:
|
||||
model_version = request.model_versions.get(model_group, request.model_version)
|
||||
loaded.append(engine.load(model_group, model_version))
|
||||
except Exception as exc:
|
||||
warnings.append({"code": "MODEL_WARMUP_FAILED", "model_group": model_group, "message": str(exc)})
|
||||
return {"loaded": loaded, "warnings": warnings, "runtime": engine.runtime_status()}
|
||||
|
||||
@router.post("/infer-frame")
|
||||
async def infer_frame(
|
||||
frame: Annotated[UploadFile, File()],
|
||||
session_id: Annotated[str, Form()] = "default",
|
||||
timestamp_ms: Annotated[float, Form()] = 0,
|
||||
source_width: Annotated[int | None, Form()] = None,
|
||||
source_height: Annotated[int | None, Form()] = None,
|
||||
detect_enabled: Annotated[bool, Form()] = True,
|
||||
segment_enabled: Annotated[bool, Form()] = False,
|
||||
confidence_threshold: Annotated[float, Form()] = 0.45,
|
||||
mask_threshold: Annotated[float, Form()] = 0.5,
|
||||
max_detections: Annotated[int, Form()] = 100,
|
||||
max_inference_width: Annotated[int, Form()] = 960,
|
||||
detection_scene: Annotated[str, Form()] = "inspection",
|
||||
detection_model_version: Annotated[str | None, Form()] = None,
|
||||
segmentation_model_version: Annotated[str | None, Form()] = None,
|
||||
) -> dict:
|
||||
payload = await frame.read(MAX_FRAME_UPLOAD_BYTES + 1)
|
||||
max_bytes = MAX_FRAME_UPLOAD_BYTES
|
||||
if len(payload) > max_bytes:
|
||||
raise HTTPException(status_code=413, detail=f"单帧大小超过 {max_bytes // (1024 * 1024)}MB")
|
||||
try:
|
||||
image = frame_service.decode_image(payload)
|
||||
response = frame_service.infer_frame(
|
||||
image,
|
||||
detect_enabled=detect_enabled,
|
||||
segment_enabled=segment_enabled,
|
||||
confidence_threshold=confidence_threshold,
|
||||
mask_threshold=mask_threshold,
|
||||
max_detections=max_detections,
|
||||
max_inference_width=max_inference_width,
|
||||
detection_scene=detection_scene,
|
||||
detection_model_version=detection_model_version,
|
||||
segmentation_model_version=segmentation_model_version,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
response.update(
|
||||
{
|
||||
"session_id": session_id,
|
||||
"timestamp_ms": timestamp_ms,
|
||||
"frame_id": f"{session_id}:{round(timestamp_ms)}",
|
||||
}
|
||||
)
|
||||
if source_width and source_height:
|
||||
response["source"]["reported_width"] = source_width
|
||||
response["source"]["reported_height"] = source_height
|
||||
return response
|
||||
|
||||
@router.post("/export-jobs", response_model=VideoExportJobCreateResponse)
|
||||
async def create_export_job(
|
||||
background_tasks: BackgroundTasks,
|
||||
video: Annotated[UploadFile, File()],
|
||||
detect_enabled: Annotated[bool, Form()] = True,
|
||||
segment_enabled: Annotated[bool, Form()] = False,
|
||||
confidence_threshold: Annotated[float, Form()] = 0.45,
|
||||
mask_threshold: Annotated[float, Form()] = 0.5,
|
||||
max_inference_width: Annotated[int, Form()] = 960,
|
||||
output_fps_policy: Annotated[str, Form()] = "source",
|
||||
analysis_stride: Annotated[int, Form()] = 1,
|
||||
reuse_last_result: Annotated[bool, Form()] = True,
|
||||
max_detections: Annotated[int, Form()] = 100,
|
||||
detection_scene: Annotated[str, Form()] = "inspection",
|
||||
detection_model_version: Annotated[str | None, Form()] = None,
|
||||
segmentation_model_version: Annotated[str | None, Form()] = None,
|
||||
) -> dict:
|
||||
if output_fps_policy not in {"source", "fixed"}:
|
||||
raise HTTPException(status_code=400, detail="output_fps_policy 仅支持 source 或 fixed")
|
||||
if not detect_enabled and not segment_enabled:
|
||||
raise HTTPException(status_code=400, detail="至少需要开启目标检测或图像分割")
|
||||
try:
|
||||
job = await export_manager.create_job(
|
||||
video,
|
||||
detect_enabled=detect_enabled,
|
||||
segment_enabled=segment_enabled,
|
||||
confidence_threshold=confidence_threshold,
|
||||
mask_threshold=mask_threshold,
|
||||
max_inference_width=max_inference_width,
|
||||
analysis_stride=analysis_stride,
|
||||
reuse_last_result=reuse_last_result,
|
||||
max_detections=max_detections,
|
||||
detection_scene=detection_scene,
|
||||
detection_model_version=detection_model_version,
|
||||
segmentation_model_version=segmentation_model_version,
|
||||
)
|
||||
except VideoUploadError as exc:
|
||||
raise HTTPException(status_code=exc.status_code, detail=str(exc)) from exc
|
||||
background_tasks.add_task(export_manager.run_job, job["run_id"])
|
||||
return {
|
||||
**job,
|
||||
"status_url": f"/api/v1/video-demo/export-jobs/{job['run_id']}",
|
||||
}
|
||||
|
||||
@router.get("/export-jobs/{run_id}", response_model=VideoExportJobStatus)
|
||||
def export_job_status(run_id: str) -> dict:
|
||||
state = export_manager.status(run_id)
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="导出任务不存在")
|
||||
return state
|
||||
|
||||
@router.post("/export-jobs/{run_id}/cancel", response_model=VideoExportJobStatus)
|
||||
def cancel_export_job(run_id: str) -> dict:
|
||||
try:
|
||||
state = export_manager.cancel(run_id)
|
||||
except VideoExportStateError as exc:
|
||||
raise HTTPException(status_code=409, detail=str(exc)) from exc
|
||||
if state is None:
|
||||
raise HTTPException(status_code=404, detail="video export job does not exist")
|
||||
return state
|
||||
|
||||
@router.get("/export-jobs/{run_id}/files/{file_name}")
|
||||
def export_job_file(run_id: str, file_name: str) -> FileResponse:
|
||||
path = export_manager.file_path(run_id, file_name)
|
||||
if path is None:
|
||||
raise HTTPException(status_code=404, detail="输出文件不存在")
|
||||
media_type = "video/mp4" if file_name.endswith(".mp4") else "application/octet-stream"
|
||||
return FileResponse(path, media_type=media_type, filename=file_name)
|
||||
|
||||
return router
|
||||
|
||||
|
||||
def _gpu_info() -> dict | None:
|
||||
try:
|
||||
completed = subprocess.run(
|
||||
["nvidia-smi", "--query-gpu=name,memory.total,driver_version", "--format=csv,noheader,nounits"],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
text=True,
|
||||
timeout=3,
|
||||
)
|
||||
except Exception:
|
||||
return None
|
||||
first = completed.stdout.strip().splitlines()[0] if completed.stdout.strip() else ""
|
||||
parts = [item.strip() for item in first.split(",")]
|
||||
if len(parts) < 3:
|
||||
return None
|
||||
return {"name": parts[0], "memory_total_mb": int(float(parts[1])), "driver_version": parts[2]}
|
||||
@@ -0,0 +1,57 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Any, Literal
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
VideoExportJobState = Literal["queued", "running", "succeeded", "failed", "cancelled"]
|
||||
|
||||
|
||||
class VideoDemoWarning(BaseModel):
|
||||
code: str
|
||||
message: str
|
||||
model_group: str | None = None
|
||||
|
||||
|
||||
class WarmupRequest(BaseModel):
|
||||
models: list[str] = Field(default_factory=list)
|
||||
model_version: str | None = None
|
||||
model_versions: dict[str, str] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class VideoExportJobCreateResponse(BaseModel):
|
||||
run_id: str
|
||||
status: VideoExportJobState
|
||||
output_dir: str
|
||||
status_url: str
|
||||
|
||||
|
||||
class VideoExportProgress(BaseModel):
|
||||
processed_frames: int = 0
|
||||
total_frames: int | None = None
|
||||
percent: float = 0.0
|
||||
elapsed_seconds: float = 0.0
|
||||
eta_seconds: float | None = None
|
||||
|
||||
|
||||
class VideoExportOutputs(BaseModel):
|
||||
annotated_video: str | None = None
|
||||
results_json: str | None = None
|
||||
results_jsonl: str | None = None
|
||||
metadata_json: str | None = None
|
||||
log: str | None = None
|
||||
|
||||
|
||||
class VideoExportJobStatus(BaseModel):
|
||||
run_id: str
|
||||
status: VideoExportJobState
|
||||
progress: VideoExportProgress
|
||||
outputs: VideoExportOutputs
|
||||
warnings: list[VideoDemoWarning] = Field(default_factory=list)
|
||||
error: str | None = None
|
||||
cancel_requested: bool = False
|
||||
cancelled_at: str | None = None
|
||||
|
||||
|
||||
JsonMap = dict[str, Any]
|
||||
@@ -0,0 +1,671 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
import uuid
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
from typing import Any
|
||||
from urllib.parse import unquote
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
from fastapi import UploadFile
|
||||
|
||||
from .cpu_runtime import CpuVisionRuntime
|
||||
from .frame_runtime import FrameInferenceService
|
||||
|
||||
|
||||
ALLOWED_OUTPUT_FILES = {"annotated.mp4", "results.json", "results.jsonl", "run-metadata.json", "run.log"}
|
||||
DEFAULT_MAX_VIDEO_UPLOAD_BYTES = 2 * 1024 * 1024 * 1024
|
||||
VIDEO_UPLOAD_LIMIT_ENV = "RAIL_VIDEO_DEMO_MAX_VIDEO_BYTES"
|
||||
VIDEO_MIME_TYPES_BY_SUFFIX = {
|
||||
".mp4": {"video/mp4", "application/mp4"},
|
||||
".m4v": {"video/mp4", "video/x-m4v"},
|
||||
".mov": {"video/quicktime"},
|
||||
".avi": {"video/x-msvideo", "video/avi"},
|
||||
".mkv": {"video/x-matroska", "video/mkv"},
|
||||
".webm": {"video/webm"},
|
||||
}
|
||||
SAFE_RUN_ID = re.compile(r"[A-Za-z0-9][A-Za-z0-9._-]{0,127}\Z")
|
||||
|
||||
|
||||
class VideoUploadError(ValueError):
|
||||
def __init__(self, message: str, status_code: int = 400):
|
||||
super().__init__(message)
|
||||
self.status_code = status_code
|
||||
|
||||
|
||||
class VideoExportStateError(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
class VideoExportCancelled(RuntimeError):
|
||||
pass
|
||||
|
||||
|
||||
def max_video_upload_bytes() -> int:
|
||||
configured = os.getenv(VIDEO_UPLOAD_LIMIT_ENV, "").strip()
|
||||
if not configured:
|
||||
return DEFAULT_MAX_VIDEO_UPLOAD_BYTES
|
||||
try:
|
||||
value = int(configured)
|
||||
except ValueError:
|
||||
return DEFAULT_MAX_VIDEO_UPLOAD_BYTES
|
||||
return value if value > 0 else DEFAULT_MAX_VIDEO_UPLOAD_BYTES
|
||||
|
||||
|
||||
def _repo_root() -> Path:
|
||||
current = Path(__file__).resolve()
|
||||
for parent in current.parents:
|
||||
if (parent / "visualization-demo").is_dir():
|
||||
return parent
|
||||
return Path.cwd()
|
||||
|
||||
|
||||
def _output_root() -> Path:
|
||||
configured = os.getenv("RAIL_VIDEO_DEMO_OUTPUT_DIR")
|
||||
if configured:
|
||||
return Path(configured).resolve()
|
||||
return (_repo_root() / "visualization-demo" / "outputs" / "video-runs").resolve()
|
||||
|
||||
|
||||
class VideoExportManager:
|
||||
def __init__(self, runtime: CpuVisionRuntime, output_root: Path | None = None):
|
||||
self.runtime = runtime
|
||||
self.frame_service = FrameInferenceService(runtime)
|
||||
self.output_root = (output_root or _output_root()).resolve()
|
||||
self.output_root.mkdir(parents=True, exist_ok=True)
|
||||
self._lock = threading.RLock()
|
||||
self._jobs: dict[str, dict[str, Any]] = {}
|
||||
self._cancel_events: dict[str, threading.Event] = {}
|
||||
|
||||
async def create_job(
|
||||
self,
|
||||
upload: UploadFile,
|
||||
*,
|
||||
detect_enabled: bool,
|
||||
segment_enabled: bool,
|
||||
confidence_threshold: float,
|
||||
mask_threshold: float,
|
||||
max_inference_width: int,
|
||||
analysis_stride: int,
|
||||
reuse_last_result: bool,
|
||||
max_detections: int,
|
||||
detection_scene: str,
|
||||
detection_model_version: str | None,
|
||||
segmentation_model_version: str | None,
|
||||
) -> dict[str, Any]:
|
||||
source_suffix = self._validate_upload(upload)
|
||||
upload_limit = max_video_upload_bytes()
|
||||
if upload.size is not None and upload.size > upload_limit:
|
||||
raise VideoUploadError(
|
||||
f"video upload exceeds the configured limit of {upload_limit} bytes",
|
||||
status_code=413,
|
||||
)
|
||||
|
||||
run_id = self._new_run_id()
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
raise RuntimeError("generated video export run id is invalid")
|
||||
run_dir.mkdir(parents=True, exist_ok=False)
|
||||
|
||||
source_path = run_dir / f"source{source_suffix}"
|
||||
written = 0
|
||||
try:
|
||||
with source_path.open("wb") as target:
|
||||
while chunk := await upload.read(1024 * 1024):
|
||||
written += len(chunk)
|
||||
if written > upload_limit:
|
||||
raise VideoUploadError(
|
||||
f"video upload exceeds the configured limit of {upload_limit} bytes",
|
||||
status_code=413,
|
||||
)
|
||||
target.write(chunk)
|
||||
if written == 0:
|
||||
raise VideoUploadError("video upload is empty")
|
||||
except Exception:
|
||||
source_path.unlink(missing_ok=True)
|
||||
try:
|
||||
run_dir.rmdir()
|
||||
except OSError:
|
||||
pass
|
||||
raise
|
||||
|
||||
config = {
|
||||
"detect_enabled": detect_enabled,
|
||||
"segment_enabled": segment_enabled,
|
||||
"confidence_threshold": confidence_threshold,
|
||||
"mask_threshold": mask_threshold,
|
||||
"max_inference_width": max_inference_width,
|
||||
"analysis_stride": max(1, analysis_stride),
|
||||
"reuse_last_result": reuse_last_result,
|
||||
"max_detections": max(1, max_detections),
|
||||
"detection_scene": detection_scene,
|
||||
"detection_model_version": detection_model_version,
|
||||
"segmentation_model_version": segmentation_model_version,
|
||||
"source_path": str(source_path),
|
||||
"source_name": upload.filename or source_path.name,
|
||||
"source_size_bytes": written,
|
||||
}
|
||||
state = self._state(run_id, "queued", run_dir)
|
||||
self._save_json(run_dir / "status.json", state)
|
||||
self._save_json(run_dir / "run-metadata.json", {"run_id": run_id, "created_at": self._now(), "config": config})
|
||||
with self._lock:
|
||||
self._jobs[run_id] = {**state, "config": config}
|
||||
self._cancel_events[run_id] = threading.Event()
|
||||
return {"run_id": run_id, "status": "queued", "output_dir": str(run_dir)}
|
||||
|
||||
def run_job(self, run_id: str) -> None:
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
return
|
||||
with self._lock:
|
||||
job = self._jobs.get(run_id)
|
||||
if not job:
|
||||
return
|
||||
|
||||
config = dict(job["config"])
|
||||
log_path = run_dir / "run.log"
|
||||
source_path = Path(config["source_path"])
|
||||
annotated_path = run_dir / "annotated.mp4"
|
||||
results_jsonl_path = run_dir / "results.jsonl"
|
||||
results_json_path = run_dir / "results.json"
|
||||
metadata_path = run_dir / "run-metadata.json"
|
||||
|
||||
started = time.perf_counter()
|
||||
warnings: list[dict[str, Any]] = []
|
||||
frame_records: list[dict[str, Any]] = []
|
||||
frame_index = 0
|
||||
total_frames: int | None = None
|
||||
metadata: dict[str, Any] = {}
|
||||
capture: cv2.VideoCapture | None = None
|
||||
writer: cv2.VideoWriter | None = None
|
||||
try:
|
||||
self._raise_if_cancelled(run_id)
|
||||
running_state = self._update(run_id, "running", processed_frames=0, total_frames=None, warnings=warnings)
|
||||
if running_state["status"] != "running":
|
||||
raise VideoExportCancelled()
|
||||
self._append_log(log_path, f"started source={source_path}")
|
||||
|
||||
capture = cv2.VideoCapture(str(source_path))
|
||||
if not capture.isOpened():
|
||||
raise RuntimeError(f"无法打开视频文件: {source_path}")
|
||||
|
||||
width = int(capture.get(cv2.CAP_PROP_FRAME_WIDTH) or 0)
|
||||
height = int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT) or 0)
|
||||
fps = float(capture.get(cv2.CAP_PROP_FPS) or 0) or 25.0
|
||||
total_frames_raw = int(capture.get(cv2.CAP_PROP_FRAME_COUNT) or 0)
|
||||
total_frames = total_frames_raw if total_frames_raw > 0 else None
|
||||
if width <= 0 or height <= 0:
|
||||
raise RuntimeError("视频宽高无效,无法生成结果视频")
|
||||
|
||||
fourcc = cv2.VideoWriter_fourcc(*"mp4v")
|
||||
writer = cv2.VideoWriter(str(annotated_path), fourcc, fps, (width, height))
|
||||
if not writer.isOpened():
|
||||
raise RuntimeError("OpenCV VideoWriter 无法创建 annotated.mp4")
|
||||
|
||||
metadata = {
|
||||
"run_id": run_id,
|
||||
"created_at": self._now(),
|
||||
"started_at": self._now(),
|
||||
"source": {"path": str(source_path), "name": config["source_name"], "width": width, "height": height, "fps": fps, "frames": total_frames},
|
||||
"output": {"path": str(annotated_path), "codec": "mp4v"},
|
||||
"runtime": {"provider": self.runtime.settings.execution_provider},
|
||||
"config": config,
|
||||
}
|
||||
self._save_json(metadata_path, metadata)
|
||||
|
||||
last_inference: dict[str, Any] | None = None
|
||||
warning_keys: set[tuple[str, str, str]] = set()
|
||||
with results_jsonl_path.open("w", encoding="utf-8") as stream:
|
||||
while True:
|
||||
self._raise_if_cancelled(run_id)
|
||||
ok, frame = capture.read()
|
||||
if not ok:
|
||||
break
|
||||
|
||||
should_infer = frame_index % int(config["analysis_stride"]) == 0
|
||||
if should_infer or not config["reuse_last_result"] or last_inference is None:
|
||||
inference = self.frame_service.infer_frame(
|
||||
frame,
|
||||
detect_enabled=bool(config["detect_enabled"]),
|
||||
segment_enabled=bool(config["segment_enabled"]),
|
||||
confidence_threshold=float(config["confidence_threshold"]),
|
||||
mask_threshold=float(config["mask_threshold"]),
|
||||
max_detections=int(config["max_detections"]),
|
||||
max_inference_width=int(config["max_inference_width"]),
|
||||
detection_scene=str(config.get("detection_scene") or "inspection"),
|
||||
detection_model_version=config.get("detection_model_version"),
|
||||
segmentation_model_version=config.get("segmentation_model_version"),
|
||||
)
|
||||
last_inference = inference
|
||||
self._merge_warnings(warnings, warning_keys, inference.get("warnings", []))
|
||||
else:
|
||||
inference = last_inference
|
||||
|
||||
# Model inference is the longest non-interruptible operation.
|
||||
# Check again before writing so cancellation stops on this frame.
|
||||
self._raise_if_cancelled(run_id)
|
||||
|
||||
timestamp_ms = round(frame_index * 1000 / fps, 2)
|
||||
record = {
|
||||
"frame_index": frame_index,
|
||||
"timestamp_ms": timestamp_ms,
|
||||
"inferred": should_infer,
|
||||
"results": inference["results"],
|
||||
"runtime": inference["runtime"],
|
||||
"warnings": inference.get("warnings", []),
|
||||
}
|
||||
stream.write(json.dumps(record, ensure_ascii=False) + "\n")
|
||||
if len(frame_records) < 5000:
|
||||
frame_records.append(record)
|
||||
|
||||
writer.write(self._overlay(frame, inference, frame_index, timestamp_ms))
|
||||
frame_index += 1
|
||||
|
||||
if frame_index % 10 == 0:
|
||||
elapsed = time.perf_counter() - started
|
||||
eta = self._eta(elapsed, frame_index, total_frames)
|
||||
self._update(
|
||||
run_id,
|
||||
"running",
|
||||
processed_frames=frame_index,
|
||||
total_frames=total_frames,
|
||||
elapsed_seconds=elapsed,
|
||||
eta_seconds=eta,
|
||||
warnings=warnings[-20:],
|
||||
)
|
||||
|
||||
self._raise_if_cancelled(run_id)
|
||||
elapsed = time.perf_counter() - started
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"status": "succeeded",
|
||||
"source": metadata["source"],
|
||||
"outputs": {
|
||||
"annotated_video": str(annotated_path),
|
||||
"results_json": str(results_json_path),
|
||||
"results_jsonl": str(results_jsonl_path),
|
||||
"metadata_json": str(metadata_path),
|
||||
"log": str(log_path),
|
||||
},
|
||||
"processed_frames": frame_index,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"sample_records": frame_records,
|
||||
"warnings": warnings[-100:],
|
||||
}
|
||||
self._save_json(results_json_path, summary)
|
||||
metadata["finished_at"] = self._now()
|
||||
metadata["elapsed_seconds"] = round(elapsed, 2)
|
||||
self._save_json(metadata_path, metadata)
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
final_state = self._update(
|
||||
run_id,
|
||||
"succeeded",
|
||||
processed_frames=frame_index,
|
||||
total_frames=total_frames or frame_index,
|
||||
elapsed_seconds=elapsed,
|
||||
eta_seconds=0,
|
||||
warnings=warnings[-20:],
|
||||
)
|
||||
if final_state["status"] != "succeeded":
|
||||
raise VideoExportCancelled()
|
||||
self._append_log(log_path, f"succeeded frames={frame_index} elapsed={elapsed:.2f}s")
|
||||
except VideoExportCancelled:
|
||||
elapsed = time.perf_counter() - started
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
writer = None
|
||||
if capture is not None:
|
||||
capture.release()
|
||||
capture = None
|
||||
summary = {
|
||||
"run_id": run_id,
|
||||
"status": "cancelled",
|
||||
"source": metadata.get("source", {"path": str(source_path), "name": config["source_name"]}),
|
||||
"outputs": {
|
||||
"annotated_video": str(annotated_path) if annotated_path.is_file() else None,
|
||||
"results_json": str(results_json_path),
|
||||
"results_jsonl": str(results_jsonl_path) if results_jsonl_path.is_file() else None,
|
||||
"metadata_json": str(metadata_path),
|
||||
"log": str(log_path),
|
||||
},
|
||||
"processed_frames": frame_index,
|
||||
"elapsed_seconds": round(elapsed, 2),
|
||||
"sample_records": frame_records,
|
||||
"warnings": warnings[-100:],
|
||||
}
|
||||
self._save_json(results_json_path, summary)
|
||||
if not metadata:
|
||||
metadata = {
|
||||
"run_id": run_id,
|
||||
"created_at": self._now(),
|
||||
"source": {"path": str(source_path), "name": config["source_name"]},
|
||||
"config": config,
|
||||
}
|
||||
metadata["cancelled_at"] = self._now()
|
||||
metadata["elapsed_seconds"] = round(elapsed, 2)
|
||||
self._save_json(metadata_path, metadata)
|
||||
self._update(
|
||||
run_id,
|
||||
"cancelled",
|
||||
processed_frames=frame_index,
|
||||
total_frames=total_frames,
|
||||
elapsed_seconds=elapsed,
|
||||
eta_seconds=None,
|
||||
warnings=warnings[-20:],
|
||||
cancel_requested=True,
|
||||
cancelled_at=metadata["cancelled_at"],
|
||||
)
|
||||
self._append_log(log_path, f"cancelled frames={frame_index} elapsed={elapsed:.2f}s")
|
||||
except Exception as exc:
|
||||
elapsed = time.perf_counter() - started
|
||||
failed_state = self._update(
|
||||
run_id,
|
||||
"failed",
|
||||
elapsed_seconds=elapsed,
|
||||
error=f"{type(exc).__name__}: {exc}",
|
||||
warnings=warnings[-20:],
|
||||
)
|
||||
if failed_state["status"] == "cancelled":
|
||||
self._append_log(log_path, f"stopped after cancellation {type(exc).__name__}: {exc}")
|
||||
else:
|
||||
self._append_log(log_path, f"failed {type(exc).__name__}: {exc}")
|
||||
finally:
|
||||
if capture is not None:
|
||||
capture.release()
|
||||
if writer is not None:
|
||||
writer.release()
|
||||
|
||||
def status(self, run_id: str) -> dict[str, Any] | None:
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
return None
|
||||
with self._lock:
|
||||
state = self._jobs.get(run_id)
|
||||
if state:
|
||||
return self._public_state(state)
|
||||
path = run_dir / "status.json"
|
||||
if path.is_file():
|
||||
return json.loads(path.read_text(encoding="utf-8"))
|
||||
return None
|
||||
|
||||
def cancel(self, run_id: str) -> dict[str, Any] | None:
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
return None
|
||||
with self._lock:
|
||||
state = self._jobs.get(run_id)
|
||||
if state is None:
|
||||
status_path = run_dir / "status.json"
|
||||
if not status_path.is_file():
|
||||
return None
|
||||
state = json.loads(status_path.read_text(encoding="utf-8"))
|
||||
|
||||
current_status = str(state.get("status", ""))
|
||||
if current_status in {"succeeded", "failed"}:
|
||||
raise VideoExportStateError(f"video export job is already {current_status}")
|
||||
if current_status == "cancelled":
|
||||
return self._public_state(state)
|
||||
if current_status not in {"queued", "running"}:
|
||||
raise VideoExportStateError(f"video export job cannot be cancelled from {current_status or 'unknown'}")
|
||||
|
||||
event = self._cancel_events.setdefault(run_id, threading.Event())
|
||||
event.set()
|
||||
progress = dict(state.get("progress") or {})
|
||||
cancelled_at = self._now()
|
||||
cancelled = self._state(
|
||||
run_id,
|
||||
"cancelled",
|
||||
run_dir,
|
||||
processed_frames=int(progress.get("processed_frames") or 0),
|
||||
total_frames=progress.get("total_frames"),
|
||||
elapsed_seconds=float(progress.get("elapsed_seconds") or 0),
|
||||
eta_seconds=None,
|
||||
warnings=list(state.get("warnings") or []),
|
||||
cancel_requested=True,
|
||||
cancelled_at=cancelled_at,
|
||||
)
|
||||
config = state.get("config")
|
||||
self._jobs[run_id] = {**cancelled, **({"config": config} if config else {})}
|
||||
self._save_json(run_dir / "status.json", cancelled)
|
||||
|
||||
self._append_log(run_dir / "run.log", f"cancel requested previous_status={current_status}")
|
||||
return cancelled
|
||||
|
||||
def file_path(self, run_id: str, file_name: str) -> Path | None:
|
||||
if file_name not in ALLOWED_OUTPUT_FILES:
|
||||
return None
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
return None
|
||||
path = (run_dir / file_name).resolve()
|
||||
try:
|
||||
self._ensure_inside_root(path)
|
||||
except ValueError:
|
||||
return None
|
||||
return path if path.is_file() else None
|
||||
|
||||
def _overlay(self, frame: np.ndarray, inference: dict[str, Any], frame_index: int, timestamp_ms: float) -> np.ndarray:
|
||||
output = frame.copy()
|
||||
overlay = output.copy()
|
||||
height, width = output.shape[:2]
|
||||
|
||||
for segment in inference["results"].get("segments", []):
|
||||
color = self._color(str(segment.get("category", "segment")))
|
||||
mask = self._decode_mask(segment.get("mask"))
|
||||
if mask is not None:
|
||||
scaled = cv2.resize(mask, (width, height), interpolation=cv2.INTER_NEAREST)
|
||||
active = scaled > 0
|
||||
overlay[active] = color
|
||||
contours, _ = cv2.findContours((active.astype(np.uint8) * 255), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE)
|
||||
if contours:
|
||||
cv2.drawContours(output, contours, -1, color, 2, cv2.LINE_AA)
|
||||
continue
|
||||
polygon = segment.get("polygon") or []
|
||||
if len(polygon) < 3:
|
||||
continue
|
||||
points = np.asarray(
|
||||
[[int(self._clip(point[0]) * width), int(self._clip(point[1]) * height)] for point in polygon],
|
||||
dtype=np.int32,
|
||||
)
|
||||
cv2.fillPoly(overlay, [points], color)
|
||||
cv2.polylines(output, [points], True, color, 2, cv2.LINE_AA)
|
||||
output = cv2.addWeighted(overlay, 0.35, output, 0.65, 0)
|
||||
|
||||
for detection in inference["results"].get("detections", []):
|
||||
bbox = detection.get("bbox") or []
|
||||
if len(bbox) != 4:
|
||||
continue
|
||||
x1, y1, x2, y2 = bbox
|
||||
left = int(self._clip(x1) * width)
|
||||
top = int(self._clip(y1) * height)
|
||||
right = int(self._clip(x2) * width)
|
||||
bottom = int(self._clip(y2) * height)
|
||||
color = self._color(str(detection.get("category", "target")))
|
||||
cv2.rectangle(output, (left, top), (right, bottom), color, 2)
|
||||
label = f"{detection.get('category', 'target')} {float(detection.get('confidence', 0)):.2f}"
|
||||
cv2.putText(output, label, (left, max(18, top - 6)), cv2.FONT_HERSHEY_SIMPLEX, 0.55, color, 2, cv2.LINE_AA)
|
||||
|
||||
cv2.putText(output, f"frame {frame_index} {timestamp_ms / 1000:.2f}s", (12, 28), cv2.FONT_HERSHEY_SIMPLEX, 0.7, (255, 255, 255), 2, cv2.LINE_AA)
|
||||
cv2.putText(output, "Demo - not production alarm evidence", (12, height - 16), cv2.FONT_HERSHEY_SIMPLEX, 0.55, (255, 255, 255), 2, cv2.LINE_AA)
|
||||
return output
|
||||
|
||||
def _state(
|
||||
self,
|
||||
run_id: str,
|
||||
status: str,
|
||||
run_dir: Path,
|
||||
*,
|
||||
processed_frames: int = 0,
|
||||
total_frames: int | None = None,
|
||||
elapsed_seconds: float = 0.0,
|
||||
eta_seconds: float | None = None,
|
||||
warnings: list[dict[str, Any]] | None = None,
|
||||
error: str | None = None,
|
||||
cancel_requested: bool = False,
|
||||
cancelled_at: str | None = None,
|
||||
) -> dict[str, Any]:
|
||||
percent = round(processed_frames * 100 / total_frames, 2) if total_frames else 0.0
|
||||
return {
|
||||
"run_id": run_id,
|
||||
"status": status,
|
||||
"progress": {
|
||||
"processed_frames": processed_frames,
|
||||
"total_frames": total_frames,
|
||||
"percent": percent,
|
||||
"elapsed_seconds": round(elapsed_seconds, 2),
|
||||
"eta_seconds": round(eta_seconds, 2) if eta_seconds is not None else None,
|
||||
},
|
||||
"outputs": {
|
||||
"annotated_video": str(run_dir / "annotated.mp4") if (run_dir / "annotated.mp4").is_file() else None,
|
||||
"results_json": str(run_dir / "results.json") if (run_dir / "results.json").is_file() else None,
|
||||
"results_jsonl": str(run_dir / "results.jsonl") if (run_dir / "results.jsonl").is_file() else None,
|
||||
"metadata_json": str(run_dir / "run-metadata.json") if (run_dir / "run-metadata.json").is_file() else None,
|
||||
"log": str(run_dir / "run.log"),
|
||||
},
|
||||
"warnings": warnings or [],
|
||||
"error": error,
|
||||
"cancel_requested": cancel_requested,
|
||||
"cancelled_at": cancelled_at,
|
||||
}
|
||||
|
||||
def _update(self, run_id: str, status: str, **kwargs: Any) -> dict[str, Any]:
|
||||
run_dir = self._run_dir(run_id)
|
||||
if run_dir is None:
|
||||
raise ValueError(f"invalid video export run id: {run_id}")
|
||||
with self._lock:
|
||||
previous = self._jobs.get(run_id, {})
|
||||
if previous.get("status") == "cancelled" and status != "cancelled":
|
||||
return self._public_state(previous)
|
||||
config = previous.get("config")
|
||||
state = self._state(run_id, status, run_dir, **kwargs)
|
||||
self._jobs[run_id] = {**state, **({"config": config} if config else {})}
|
||||
self._save_json(run_dir / "status.json", state)
|
||||
return state
|
||||
|
||||
def _public_state(self, state: dict[str, Any]) -> dict[str, Any]:
|
||||
return {key: value for key, value in state.items() if key != "config"}
|
||||
|
||||
def _new_run_id(self) -> str:
|
||||
return f"video-run-{datetime.now().strftime('%Y%m%d-%H%M%S')}-{uuid.uuid4().hex[:6]}"
|
||||
|
||||
def _validate_upload(self, upload: UploadFile) -> str:
|
||||
filename = (upload.filename or "").strip()
|
||||
decoded_name = unquote(filename)
|
||||
if not decoded_name:
|
||||
raise VideoUploadError("video filename is required")
|
||||
if "\x00" in decoded_name or "/" in decoded_name or "\\" in decoded_name or decoded_name in {".", ".."}:
|
||||
raise VideoUploadError("video filename must not contain a path")
|
||||
suffix = Path(decoded_name).suffix.lower()
|
||||
allowed_mime_types = VIDEO_MIME_TYPES_BY_SUFFIX.get(suffix)
|
||||
if allowed_mime_types is None:
|
||||
supported = ", ".join(sorted(VIDEO_MIME_TYPES_BY_SUFFIX))
|
||||
raise VideoUploadError(f"unsupported video extension; supported extensions: {supported}")
|
||||
content_type = (upload.content_type or "").split(";", 1)[0].strip().lower()
|
||||
if content_type not in allowed_mime_types:
|
||||
raise VideoUploadError(
|
||||
f"content type {content_type or '<missing>'} does not match {suffix}",
|
||||
status_code=415,
|
||||
)
|
||||
return suffix
|
||||
|
||||
def _run_dir(self, run_id: str) -> Path | None:
|
||||
decoded = unquote(str(run_id))
|
||||
if decoded != run_id or not SAFE_RUN_ID.fullmatch(decoded) or decoded in {".", ".."}:
|
||||
return None
|
||||
path = (self.output_root / decoded).resolve()
|
||||
try:
|
||||
self._ensure_inside_root(path)
|
||||
except ValueError:
|
||||
return None
|
||||
return path
|
||||
|
||||
def _raise_if_cancelled(self, run_id: str) -> None:
|
||||
with self._lock:
|
||||
event = self._cancel_events.get(run_id)
|
||||
state = self._jobs.get(run_id)
|
||||
cancelled = bool(event and event.is_set()) or bool(state and state.get("status") == "cancelled")
|
||||
if cancelled:
|
||||
raise VideoExportCancelled()
|
||||
|
||||
def _ensure_inside_root(self, path: Path) -> None:
|
||||
root = self.output_root.resolve()
|
||||
resolved = path.resolve()
|
||||
if root != resolved and root not in resolved.parents:
|
||||
raise ValueError(f"输出路径越界: {resolved}")
|
||||
|
||||
@staticmethod
|
||||
def _decode_mask(mask: Any) -> np.ndarray | None:
|
||||
if not isinstance(mask, dict) or mask.get("encoding") != "rle":
|
||||
return None
|
||||
try:
|
||||
width = int(mask.get("width", 0))
|
||||
height = int(mask.get("height", 0))
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
counts = mask.get("counts")
|
||||
if width <= 0 or height <= 0 or not isinstance(counts, list):
|
||||
return None
|
||||
total = width * height
|
||||
flat = np.zeros(total, dtype=np.uint8)
|
||||
offset = 0
|
||||
value = 0
|
||||
for raw_run in counts:
|
||||
try:
|
||||
run_length = int(raw_run)
|
||||
except (TypeError, ValueError):
|
||||
return None
|
||||
if run_length < 0:
|
||||
return None
|
||||
end = min(total, offset + run_length)
|
||||
if value:
|
||||
flat[offset:end] = 1
|
||||
offset = end
|
||||
if offset >= total:
|
||||
break
|
||||
value = 1 - value
|
||||
return flat.reshape((height, width))
|
||||
|
||||
@staticmethod
|
||||
def _save_json(path: Path, payload: dict[str, Any]) -> None:
|
||||
path.write_text(json.dumps(payload, ensure_ascii=False, indent=2), encoding="utf-8")
|
||||
|
||||
@staticmethod
|
||||
def _append_log(path: Path, message: str) -> None:
|
||||
with path.open("a", encoding="utf-8") as stream:
|
||||
stream.write(f"{datetime.now().isoformat(timespec='seconds')} {message}\n")
|
||||
|
||||
@staticmethod
|
||||
def _eta(elapsed: float, processed: int, total: int | None) -> float | None:
|
||||
if not total or processed <= 0:
|
||||
return None
|
||||
return max(0.0, elapsed / processed * (total - processed))
|
||||
|
||||
@staticmethod
|
||||
def _merge_warnings(target: list[dict[str, Any]], keys: set[tuple[str, str, str]], incoming: list[dict[str, Any]]) -> None:
|
||||
for item in incoming:
|
||||
key = (str(item.get("code", "")), str(item.get("model_group", "")), str(item.get("message", "")))
|
||||
if key in keys:
|
||||
continue
|
||||
keys.add(key)
|
||||
target.append(item)
|
||||
|
||||
@staticmethod
|
||||
def _now() -> str:
|
||||
return datetime.now().isoformat(timespec="seconds")
|
||||
|
||||
@staticmethod
|
||||
def _clip(value: float) -> float:
|
||||
return min(1.0, max(0.0, float(value)))
|
||||
|
||||
@staticmethod
|
||||
def _color(label: str) -> tuple[int, int, int]:
|
||||
seed = abs(hash(label))
|
||||
return 64 + seed % 160, 64 + (seed // 7) % 160, 64 + (seed // 13) % 160
|
||||
@@ -1,7 +1,8 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
pydantic==2.10.2
|
||||
python-multipart==0.0.19
|
||||
opencv-python-headless==4.10.0.84
|
||||
numpy==2.1.3
|
||||
onnxruntime-gpu==1.20.1
|
||||
onnxruntime-gpu[cuda,cudnn]==1.22.0
|
||||
minio==7.2.12
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
fastapi==0.115.5
|
||||
uvicorn[standard]==0.32.1
|
||||
pydantic==2.10.2
|
||||
python-multipart==0.0.19
|
||||
opencv-python-headless==4.10.0.84
|
||||
numpy==2.1.3
|
||||
onnxruntime==1.20.1
|
||||
|
||||
@@ -51,10 +51,12 @@ def test_cpu_runtime_reports_registered_visual_models():
|
||||
assert body["profile"] == "cpu-local"
|
||||
assert body["runtime_available"] is True
|
||||
assert body["max_concurrency"] == 1
|
||||
assert body["max_loaded_models"] == 1
|
||||
assert body["max_loaded_models"] == 2
|
||||
groups = {item["model_group"] for item in body["models"]}
|
||||
assert {"vision-detector", "vision-segmenter", "thermal-analyzer"}.issubset(groups)
|
||||
assert {item["model_version"] for item in body["models"]} == {"cpu-v1.0.0"}
|
||||
versions = {item["model_version"] for item in body["models"]}
|
||||
assert "cpu-v1.0.0" in versions
|
||||
assert "traffic-yolov8n-coco" in versions
|
||||
assert body["execution_provider_ready"] is True
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,296 @@
|
||||
import asyncio
|
||||
import io
|
||||
import json
|
||||
import threading
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
import cv2
|
||||
import numpy as np
|
||||
import pytest
|
||||
from fastapi import FastAPI, UploadFile
|
||||
from fastapi.testclient import TestClient
|
||||
from starlette.datastructures import Headers
|
||||
|
||||
from app.main import app, engine
|
||||
from app.cpu_runtime import RuntimeOutcome
|
||||
from app.frame_runtime import FrameInferenceService
|
||||
from app.video_demo_routes import MAX_FRAME_UPLOAD_BYTES, create_video_demo_router
|
||||
from app.video_export_runtime import VideoExportManager
|
||||
|
||||
|
||||
def _inference_result() -> dict:
|
||||
return {
|
||||
"results": {"detections": [], "segments": []},
|
||||
"runtime": {"provider": "test", "total_latency_ms": 0.0},
|
||||
"warnings": [],
|
||||
}
|
||||
|
||||
|
||||
def _manager(tmp_path: Path) -> VideoExportManager:
|
||||
manager = VideoExportManager(engine.runtime, tmp_path)
|
||||
manager.frame_service.infer_frame = lambda *args, **kwargs: _inference_result()
|
||||
return manager
|
||||
|
||||
|
||||
def _client(manager: VideoExportManager) -> TestClient:
|
||||
test_app = FastAPI()
|
||||
test_app.include_router(create_video_demo_router(engine, manager))
|
||||
return TestClient(test_app)
|
||||
|
||||
|
||||
def _upload(payload: bytes, filename: str = "sample.mp4", content_type: str = "video/mp4") -> UploadFile:
|
||||
return UploadFile(
|
||||
file=io.BytesIO(payload),
|
||||
size=len(payload),
|
||||
filename=filename,
|
||||
headers=Headers({"content-type": content_type}),
|
||||
)
|
||||
|
||||
|
||||
def _create_job(manager: VideoExportManager, payload: bytes, filename: str = "sample.mp4") -> dict:
|
||||
return asyncio.run(
|
||||
manager.create_job(
|
||||
_upload(payload, filename),
|
||||
detect_enabled=True,
|
||||
segment_enabled=False,
|
||||
confidence_threshold=0.45,
|
||||
mask_threshold=0.5,
|
||||
max_inference_width=640,
|
||||
analysis_stride=1,
|
||||
reuse_last_result=True,
|
||||
max_detections=10,
|
||||
detection_scene="inspection",
|
||||
detection_model_version=None,
|
||||
segmentation_model_version=None,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
def _video_bytes(path: Path, frame_count: int = 6) -> bytes:
|
||||
writer = cv2.VideoWriter(str(path), cv2.VideoWriter_fourcc(*"mp4v"), 12.0, (64, 48))
|
||||
assert writer.isOpened()
|
||||
for index in range(frame_count):
|
||||
frame = np.full((48, 64, 3), (index * 17) % 255, dtype=np.uint8)
|
||||
cv2.rectangle(frame, (8 + index % 8, 10), (32, 34), (0, 255, 0), -1)
|
||||
writer.write(frame)
|
||||
writer.release()
|
||||
payload = path.read_bytes()
|
||||
assert payload
|
||||
return payload
|
||||
|
||||
|
||||
def test_video_demo_capabilities():
|
||||
client = TestClient(app)
|
||||
response = client.get("/api/v1/video-demo/capabilities")
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert "models" in body
|
||||
assert body["recommended"]["max_inference_width"] in {640, 960}
|
||||
assert body["export"]["enabled"] is True
|
||||
|
||||
|
||||
def test_video_demo_infer_frame_reports_model_warning_when_artifact_missing():
|
||||
client = TestClient(app)
|
||||
image = np.zeros((32, 48, 3), dtype=np.uint8)
|
||||
ok, encoded = cv2.imencode(".jpg", image)
|
||||
assert ok
|
||||
|
||||
response = client.post(
|
||||
"/api/v1/video-demo/infer-frame",
|
||||
files={"frame": ("frame.jpg", encoded.tobytes(), "image/jpeg")},
|
||||
data={
|
||||
"session_id": "test-session",
|
||||
"timestamp_ms": "120",
|
||||
"detect_enabled": "true",
|
||||
"segment_enabled": "false",
|
||||
"confidence_threshold": "0.45",
|
||||
"mask_threshold": "0.5",
|
||||
"max_detections": "10",
|
||||
"max_inference_width": "640",
|
||||
},
|
||||
)
|
||||
|
||||
assert response.status_code == 200
|
||||
body = response.json()
|
||||
assert body["session_id"] == "test-session"
|
||||
assert body["source"]["width"] == 48
|
||||
assert body["source"]["height"] == 32
|
||||
assert body["results"]["detections"][0]["model_group"] == "opencv-demo-detector"
|
||||
assert body["warnings"][0]["code"] == "DEMO_FALLBACK_ACTIVE"
|
||||
assert body["warnings"][0]["model_group"] == "vision-detector"
|
||||
|
||||
|
||||
def test_frame_segments_preserve_pixel_mask_rle():
|
||||
service = FrameInferenceService(engine.runtime)
|
||||
mask = {"encoding": "rle", "width": 2, "height": 2, "counts": [1, 2, 1]}
|
||||
outcome = RuntimeOutcome(
|
||||
"onnxruntime-cpu",
|
||||
[
|
||||
{
|
||||
"category": "road",
|
||||
"confidence": 0.93,
|
||||
"geometry": {"type": "Polygon", "coordinates": [[]], "coordinate_space": "normalized"},
|
||||
"measurements": {"area_ratio": 0.5},
|
||||
"mask": mask,
|
||||
}
|
||||
],
|
||||
None,
|
||||
3.4,
|
||||
{"model_group": "vision-segmenter", "model_version": "test-segmenter"},
|
||||
)
|
||||
|
||||
segments = service._segments(outcome)
|
||||
|
||||
assert segments[0]["mask"] == mask
|
||||
assert segments[0]["polygon"] == []
|
||||
|
||||
|
||||
def test_video_export_overlay_decodes_pixel_mask_rle(tmp_path):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
frame = np.zeros((80, 80, 3), dtype=np.uint8)
|
||||
mask = {"encoding": "rle", "width": 4, "height": 4, "counts": [5, 2, 2, 2, 5]}
|
||||
inference = {
|
||||
"results": {
|
||||
"segments": [{"category": "road", "confidence": 1.0, "polygon": [], "mask": mask}],
|
||||
"detections": [],
|
||||
}
|
||||
}
|
||||
|
||||
decoded = manager._decode_mask(mask)
|
||||
output = manager._overlay(frame, inference, 0, 0)
|
||||
|
||||
assert decoded is not None
|
||||
assert int(decoded.sum()) == 4
|
||||
assert int(output[40, 40].sum()) > 0
|
||||
|
||||
|
||||
def test_video_export_job_requires_at_least_one_enabled_model():
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/video-demo/export-jobs",
|
||||
files={"video": ("sample.mp4", b"not-a-real-video", "video/mp4")},
|
||||
data={"detect_enabled": "false", "segment_enabled": "false"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
|
||||
|
||||
def test_infer_frame_rejects_payload_over_two_megabytes():
|
||||
client = TestClient(app)
|
||||
response = client.post(
|
||||
"/api/v1/video-demo/infer-frame",
|
||||
files={"frame": ("frame.jpg", b"x" * (MAX_FRAME_UPLOAD_BYTES + 1), "image/jpeg")},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
("filename", "content_type", "expected_status"),
|
||||
[
|
||||
("sample.txt", "video/mp4", 400),
|
||||
("sample.mp4", "text/plain", 415),
|
||||
("../escape.mp4", "video/mp4", 400),
|
||||
("..%2Fescape.mp4", "video/mp4", 400),
|
||||
],
|
||||
)
|
||||
def test_video_export_rejects_invalid_extension_mime_and_paths(tmp_path, filename, content_type, expected_status):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
response = _client(manager).post(
|
||||
"/api/v1/video-demo/export-jobs",
|
||||
files={"video": (filename, b"not-a-video", content_type)},
|
||||
)
|
||||
assert response.status_code == expected_status
|
||||
assert not list(manager.output_root.iterdir())
|
||||
|
||||
|
||||
def test_video_export_limit_is_configurable_and_cleans_partial_upload(tmp_path, monkeypatch):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
monkeypatch.setenv("RAIL_VIDEO_DEMO_MAX_VIDEO_BYTES", "8")
|
||||
response = _client(manager).post(
|
||||
"/api/v1/video-demo/export-jobs",
|
||||
files={"video": ("sample.mp4", b"123456789", "video/mp4")},
|
||||
)
|
||||
assert response.status_code == 413
|
||||
assert not list(manager.output_root.iterdir())
|
||||
|
||||
|
||||
def test_video_export_path_lookup_blocks_traversal(tmp_path):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
outside = tmp_path / "outside"
|
||||
outside.mkdir()
|
||||
(outside / "run.log").write_text("secret", encoding="utf-8")
|
||||
|
||||
assert manager.status("../outside") is None
|
||||
assert manager.status("..%2Foutside") is None
|
||||
assert manager.file_path("../outside", "run.log") is None
|
||||
assert manager.file_path("safe-run", "../run.log") is None
|
||||
|
||||
|
||||
def test_queued_video_export_can_be_cancelled_through_api(tmp_path):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
job = _create_job(manager, b"queued-placeholder")
|
||||
client = _client(manager)
|
||||
|
||||
response = client.post(f"/api/v1/video-demo/export-jobs/{job['run_id']}/cancel")
|
||||
assert response.status_code == 200
|
||||
assert response.json()["status"] == "cancelled"
|
||||
assert response.json()["cancel_requested"] is True
|
||||
|
||||
manager.run_job(job["run_id"])
|
||||
state = manager.status(job["run_id"])
|
||||
assert state is not None
|
||||
assert state["status"] == "cancelled"
|
||||
log = Path(state["outputs"]["log"]).read_text(encoding="utf-8")
|
||||
assert "cancel requested previous_status=queued" in log
|
||||
assert "cancelled" in log
|
||||
|
||||
|
||||
def test_running_video_export_stops_promptly_when_cancelled(tmp_path):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
payload = _video_bytes(tmp_path / "running-source.mp4", frame_count=80)
|
||||
job = _create_job(manager, payload)
|
||||
inference_started = threading.Event()
|
||||
|
||||
def slow_inference(*args, **kwargs):
|
||||
inference_started.set()
|
||||
time.sleep(0.05)
|
||||
return _inference_result()
|
||||
|
||||
manager.frame_service.infer_frame = slow_inference
|
||||
worker = threading.Thread(target=manager.run_job, args=(job["run_id"],), daemon=True)
|
||||
worker.start()
|
||||
assert inference_started.wait(timeout=3)
|
||||
|
||||
cancelled = manager.cancel(job["run_id"])
|
||||
assert cancelled is not None
|
||||
assert cancelled["status"] == "cancelled"
|
||||
worker.join(timeout=3)
|
||||
assert not worker.is_alive()
|
||||
|
||||
state = manager.status(job["run_id"])
|
||||
assert state is not None
|
||||
assert state["status"] == "cancelled"
|
||||
assert state["progress"]["processed_frames"] < 80
|
||||
assert Path(state["outputs"]["results_json"]).is_file()
|
||||
assert "cancelled" in Path(state["outputs"]["log"]).read_text(encoding="utf-8")
|
||||
|
||||
|
||||
def test_video_export_succeeds_and_publishes_expected_files(tmp_path):
|
||||
manager = _manager(tmp_path / "runs")
|
||||
payload = _video_bytes(tmp_path / "success-source.mp4", frame_count=5)
|
||||
job = _create_job(manager, payload)
|
||||
|
||||
manager.run_job(job["run_id"])
|
||||
state = manager.status(job["run_id"])
|
||||
assert state is not None
|
||||
assert state["status"] == "succeeded"
|
||||
assert state["progress"]["processed_frames"] == 5
|
||||
assert state["progress"]["percent"] == 100.0
|
||||
|
||||
for name in ("annotated_video", "results_json", "results_jsonl", "metadata_json", "log"):
|
||||
assert state["outputs"][name]
|
||||
assert Path(state["outputs"][name]).is_file()
|
||||
summary = json.loads(Path(state["outputs"]["results_json"]).read_text(encoding="utf-8"))
|
||||
assert summary["status"] == "succeeded"
|
||||
assert summary["processed_frames"] == 5
|
||||
assert manager.file_path(job["run_id"], "annotated.mp4") == Path(state["outputs"]["annotated_video"])
|
||||
Binary file not shown.
@@ -0,0 +1,128 @@
|
||||
# 铁路无人机巡检系统:三种合作方案总结
|
||||
|
||||
> 依据豆包对话整理,供内部决策和与合作方沟通使用。金额、股权及法律条款仍需结合最终需求、硬件预算和律师意见确认。
|
||||
|
||||
## 一、合作背景与共同前提
|
||||
|
||||
- 业务模式:面向政府/铁路客户提供无人机巡检设备与软件的租赁服务,收入来自持续租金,而非一次性项目回款。
|
||||
- 我方投入:从零开发巡检管理平台和铁路专项 AI 缺陷识别算法,负责技术交付、运维和迭代。
|
||||
- 对方投入:政企渠道、项目落地、商务签约,以及大疆无人机等硬件采购资金。
|
||||
- 系统价值:对话中给出的完整系统工作量为约 2500–4000 人天,公允市场价约 400–700 万元;80 万元属于本次合作的特惠价,不应被表述为软件的公允市场价值。
|
||||
- 硬件变化:放弃纵横无人机及高价激光雷达后,硬件采购范围缩小为大疆体系,初始投入明显下降;后续每增加客户,通常仍需追加设备资金。
|
||||
|
||||
## 二、三种方案总览
|
||||
|
||||
| 方案 | 核心交易 | 我方收益 | 我方责任/风险 | 适用情形 |
|
||||
|---|---|---|---|---|
|
||||
| 方案一:一次性买断 | 80 万元一次性交付完整业务包 | 回款最快、金额确定 | 放弃后续授权和经营收益;第三方许可边界和后续维护必须写清 | 希望快速回款、一次性完成业务交接 |
|
||||
| 方案二:License 授权 | 按项目/套数持续收取授权费 | 可获得持续收入,软件可复制到多客户 | 需提供约定范围内的基础维护;要防止跨项目复用和欠费 | 看好规模化业务但不愿参股,建议优先 |
|
||||
| 方案三:技术入股 | 软件技术、渠道资源和硬件现金共同形成股权 | 共享长期经营利润,收益上限最高 | 面临公司债务、财务监管、业务外流、知识产权和退出风险 | 双方已验证履约能力,准备长期共同经营 |
|
||||
|
||||
## 三、方案一:一次性完整交付、80 万元买断
|
||||
|
||||
### 合作方式
|
||||
|
||||
我方按 80 万元一次性交付完整业务包,包括平台程序、源代码、铁路专项 AI 模型、部署包、配置文档、使用说明及当前版本相关知识产权。完整业务包交付后,合作方可在约定业务范围内自行复制部署和运营;但大疆 SDK、第三方软件、开源组件及其他受第三方许可约束的内容,仅能按其原许可范围使用,不能超出许可范围转让。
|
||||
|
||||
### 优点
|
||||
|
||||
- 回款确定,现金流最清晰。
|
||||
- 不成立合资公司、不参与对方经营,不承担对方的经营负债和项目回款风险。
|
||||
- 合同和交付边界相对简单,后续沟通成本低。
|
||||
|
||||
### 缺点
|
||||
|
||||
- 收益上限锁定在 80 万元,无法分享对方后续多客户租赁业务的增长收益。
|
||||
- 交付完整业务包后,我方不再享有后续复制部署和经营分成,80 万元即为本次完整转让的主要收益。
|
||||
- 交付后对方仍可能提出免费维护、升级或新硬件适配要求,需要明确这些内容不包含在买断价内。
|
||||
|
||||
### 必须写入合同
|
||||
|
||||
1. 明确本次为完整业务包交付,包含源代码、模型、部署资料及约定范围内的著作权/使用权转移。
|
||||
2. 明确第三方 SDK、开源组件和外部服务的许可边界,不把无法转让的第三方权利写成我方可转让资产。
|
||||
3. 明确验收标准、付款节点、质保期、维护边界和新增开发收费标准;买断不等于永久免费维护。
|
||||
4. 明确对方可以在约定业务范围内复制部署,但不得将受限的第三方组件独立转售或超出许可范围分发。
|
||||
|
||||
## 四、方案二:阶梯式 License 授权租赁
|
||||
|
||||
### 合作方式与价格
|
||||
|
||||
- 首套 License:40 万元/年(续约阶段可按 4 万元/月)。
|
||||
- 第二套 License:30 万元/年(续约阶段可按 3 万元/月)。
|
||||
- 第三套及以上:20 万元/套/年(续约阶段可按 2 万元/月)。
|
||||
- 首次采购至少签订 1 个完整年度;续约时可选择年付或月付。
|
||||
- 一套 License 对应一个独立政府租赁项目,禁止一套授权跨多个项目使用。
|
||||
|
||||
### 建议的买断机制
|
||||
|
||||
合作开始 24 个月内,对方可以行使单套系统买断选择权,特惠买断价为 80 万元;超过 24 个月,特惠价格失效,买断价格按公允市场价重新评估(对话中参考 400 万元起)。
|
||||
|
||||
这里的买断按方案一延后执行:前期先按 License 收取租赁费,租赁费是独立的使用/服务费用,不抵扣 80 万元买断价。买断时仍需另行支付 80 万元,交付当时的最新稳定迭代版本完整业务包,包含源代码、模型、部署资料及约定范围内的知识产权。买断完成后,后续版本升级、算法优化和新机型适配另行计费。
|
||||
|
||||
### 授权包含与不包含
|
||||
|
||||
**包含:**当期合法使用权、现有版本 Bug 修复、基础远程运维和正常使用保障。
|
||||
|
||||
**不包含:**新增功能、算法升级、定制报表、新机型适配、现场实施、驻场服务和大规模数据处理;以上内容按单独开发合同或人天报价结算。
|
||||
|
||||
### 优点
|
||||
|
||||
- 不参股、不合伙,基本隔离合资公司的债务和治理风险。
|
||||
- 可随着项目数量增加持续获得收入,软件的可复制性能够转化为长期收益。
|
||||
- 首年年付保证最低回款,后续月付降低合作方资金压力。
|
||||
- 可以通过账号、设备或项目绑定控制授权范围,欠费时暂停服务;在买断前,合作方只能使用当期 License,不得提前复制完整业务包。
|
||||
|
||||
### 缺点
|
||||
|
||||
- 回款分期,依赖对方持续获取并签约政府租赁项目。
|
||||
- 需要长期提供合同约定范围内的维护和服务。
|
||||
- 授权控制、项目识别、数据隔离和欠费停用机制需要落地,否则容易出现一套授权多项目使用。
|
||||
|
||||
## 五、方案三:技术入股合资经营
|
||||
|
||||
### 对话中的估值逻辑
|
||||
|
||||
- 我方软件技术价值按 80 万元作为基础价值。
|
||||
- 对方政企渠道资源暂按 80 万元作为谈判基础,但渠道资源是否能够转化为实际出资,需要由律师按法律和公司登记规则处理。
|
||||
- 剩余股权根据双方实际硬件/现金出资比例核算。
|
||||
- 对话中形成的初步区间为:我方 35%–45%,对方 55%–65%,避免长期 50:50 僵局。
|
||||
|
||||
### 合作分工
|
||||
|
||||
- 我方:平台开发、算法、技术交付、运维和迭代。
|
||||
- 对方:渠道拓展、投标签约、客户维护、硬件采购和租赁业务落地。
|
||||
- 项目租金收入扣除硬件折旧、运营成本、税费和运维成本后,按约定股权或利润分配机制结算。
|
||||
|
||||
### 优点
|
||||
|
||||
- 双方共同经营,能够分享多区域、多客户租赁业务的长期利润。
|
||||
- 对方的渠道能力和我方的软件能力可以形成互补,适合需要持续投入和深度服务的业务。
|
||||
|
||||
### 缺点与主要风险
|
||||
|
||||
- 渠道资源很难像现金或知识产权一样直接作为法定出资,必须以业绩条件、分期成熟和回购机制约束。
|
||||
- 合资公司可能产生硬件采购欠款、人员成本、项目违约赔偿等经营负债。
|
||||
- 需要防范资金挪用、虚报成本、项目不入账、体外接单、分红争议和财务信息不透明。
|
||||
- 软件一旦直接转入合资公司,后续独立接单、授权和退出都会变得困难。
|
||||
|
||||
### 建议的安全结构与合同机制
|
||||
|
||||
1. 为体现合作诚意,可以将软件著作权、源代码及完整业务包按评估价值转入合资公司;转让范围、评估价格、交付时点和第三方许可边界必须在知识产权协议中单独列明。
|
||||
2. 软件资产转入应与项目落地条件、出资到账和交付验收绑定。若 24 个月内未达到约定的客户落地、签约或回款指标,应触发项目终止、资产处置或知识产权返还/回转机制,而不是默认由我方购买合资公司股权。
|
||||
3. 设置合资公司对公账户、月度财务报表、重大采购和对外合同双签机制。
|
||||
4. 明确同类政企租赁业务必须进入公司,禁止体外循环;发生侵占公司利益、恶意转移业务等情形时触发违约责任、股权处置和退出机制。
|
||||
5. 控制注册资本和双方认缴额度,明确后续增资、亏损承担和清算规则。
|
||||
|
||||
## 六、综合判断与建议顺序
|
||||
|
||||
### 推荐顺序
|
||||
|
||||
1. **首选方案二**:兼顾持续收益、知识产权控制和风险隔离,适合先验证项目和合作关系。
|
||||
2. **备选方案一**:如果最看重确定性和快速回款,直接一次性买断,但必须明确完整交付范围和后续义务。
|
||||
3. **谨慎方案三**:可以用知识产权转入合资公司的方式体现诚意,但必须绑定项目落地条件、资产返还/回转机制和清算安排;我方不承诺在对方未落地业务时收购合资公司股权。
|
||||
|
||||
### 发送给对方前的三个确认点
|
||||
|
||||
- 方案一的 80 万元是否确认包含源代码、模型、部署资料和约定范围内的知识产权转移;第三方 SDK/开源组件如何处理?
|
||||
- 方案二明确:租赁费不抵扣 80 万元买断价;买断时交付哪个“当前迭代版本”;买断后新增维护、升级和适配如何收费?
|
||||
- 方案三的软件知识产权是否转入合资公司;如果项目未落地,如何触发资产返还/回转或清算;双方确认我方不承担收购合资公司股权义务。
|
||||
File diff suppressed because it is too large
Load Diff
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -55,8 +55,8 @@ function visibleCollection() {
|
||||
function addLayers() {
|
||||
if (!map || map.getSource("railway-features")) return;
|
||||
map.addSource("railway-features", { type: "geojson", data: visibleCollection() as GeoJSON.FeatureCollection });
|
||||
map.addLayer({ id: "object-fill", type: "fill", source: "railway-features", filter: ["all", ["==", ["get", "domain_type"], "inspection_object"], ["==", ["geometry-type"], "Polygon"]], paint: { "fill-color": "#4c9c78", "fill-opacity": 0.16, "fill-outline-color": "#287257" } });
|
||||
map.addLayer({ id: "object-line", type: "line", source: "railway-features", filter: ["all", ["==", ["get", "domain_type"], "inspection_object"], ["==", ["geometry-type"], "LineString"]], paint: { "line-color": "#17365d", "line-width": 5, "line-opacity": 0.82 } });
|
||||
map.addLayer({ id: "object-fill", type: "fill", source: "railway-features", filter: ["all", ["==", ["get", "domain_type"], "inspection_object"], ["==", ["geometry-type"], "Polygon"]], paint: { "fill-color": ["case", ["==", ["to-string", ["id"]], props.selectedId], "#16a34a", "#4c9c78"], "fill-opacity": ["case", ["==", ["to-string", ["id"]], props.selectedId], 0.34, 0.16], "fill-outline-color": "#287257" } });
|
||||
map.addLayer({ id: "object-line", type: "line", source: "railway-features", filter: ["all", ["==", ["get", "domain_type"], "inspection_object"], ["==", ["geometry-type"], "LineString"]], paint: { "line-color": ["case", ["==", ["to-string", ["id"]], props.selectedId], "#16a34a", "#17365d"], "line-width": ["case", ["==", ["to-string", ["id"]], props.selectedId], 8, 5], "line-opacity": 0.82 } });
|
||||
map.addLayer({ id: "route-line", type: "line", source: "railway-features", filter: ["==", ["get", "domain_type"], "route"], paint: { "line-color": "#2563eb", "line-width": 3, "line-dasharray": [2, 2] } });
|
||||
map.addLayer({ id: "track-line", type: "line", source: "railway-features", filter: ["==", ["get", "domain_type"], "flight_track"], paint: { "line-color": "#16a34a", "line-width": 4 } });
|
||||
map.addLayer({ id: "device-point", type: "circle", source: "railway-features", filter: ["==", ["get", "domain_type"], "uav_device"], paint: { "circle-radius": 7, "circle-color": "#0f766e", "circle-stroke-color": "#ffffff", "circle-stroke-width": 2 } });
|
||||
@@ -66,6 +66,14 @@ function addLayers() {
|
||||
const properties = event.features?.[0]?.properties as Row | undefined;
|
||||
if (properties) emit("select", properties);
|
||||
});
|
||||
for (const layerId of ["object-fill", "object-line"]) {
|
||||
map.on("click", layerId, (event) => {
|
||||
const properties = event.features?.[0]?.properties as Row | undefined;
|
||||
if (properties) emit("select", { ...properties, object_id: properties.object_id || event.features?.[0]?.id });
|
||||
});
|
||||
map.on("mouseenter", layerId, () => { if (map) map.getCanvas().style.cursor = "pointer"; });
|
||||
map.on("mouseleave", layerId, () => { if (map) map.getCanvas().style.cursor = ""; });
|
||||
}
|
||||
map.on("mouseenter", "alarm-point", () => { if (map) map.getCanvas().style.cursor = "pointer"; });
|
||||
map.on("mouseleave", "alarm-point", () => { if (map) map.getCanvas().style.cursor = ""; });
|
||||
}
|
||||
@@ -79,7 +87,9 @@ function eachCoordinate(value: unknown, callback: (coordinate: [number, number])
|
||||
function fitFeatures() {
|
||||
if (!map) return;
|
||||
const bounds = new LngLatBounds();
|
||||
visibleCollection().features.forEach((feature: Row) => eachCoordinate(feature.geometry?.coordinates, (coordinate) => bounds.extend(coordinate)));
|
||||
const features = visibleCollection().features;
|
||||
const focused = props.selectedId ? features.filter((feature: Row) => String(feature.id || feature.properties?.alarm_id || feature.properties?.object_id) === props.selectedId) : [];
|
||||
(focused.length ? focused : features).forEach((feature: Row) => eachCoordinate(feature.geometry?.coordinates, (coordinate) => bounds.extend(coordinate)));
|
||||
if (!bounds.isEmpty()) map.fitBounds(bounds, { padding: 52, maxZoom: 15, duration: 0 });
|
||||
}
|
||||
|
||||
@@ -91,6 +101,14 @@ function updateData(fit = false) {
|
||||
map.setPaintProperty("alarm-point", "circle-stroke-color", ["case", ["==", ["to-string", ["id"]], props.selectedId], "#111827", "#ffffff"]);
|
||||
map.setPaintProperty("alarm-point", "circle-stroke-width", ["case", ["==", ["to-string", ["id"]], props.selectedId], 4, 2]);
|
||||
}
|
||||
if (map.getLayer("object-fill")) {
|
||||
map.setPaintProperty("object-fill", "fill-color", ["case", ["==", ["to-string", ["id"]], props.selectedId], "#16a34a", "#4c9c78"]);
|
||||
map.setPaintProperty("object-fill", "fill-opacity", ["case", ["==", ["to-string", ["id"]], props.selectedId], 0.34, 0.16]);
|
||||
}
|
||||
if (map.getLayer("object-line")) {
|
||||
map.setPaintProperty("object-line", "line-color", ["case", ["==", ["to-string", ["id"]], props.selectedId], "#16a34a", "#17365d"]);
|
||||
map.setPaintProperty("object-line", "line-width", ["case", ["==", ["to-string", ["id"]], props.selectedId], 8, 5]);
|
||||
}
|
||||
if (fit) fitFeatures();
|
||||
}
|
||||
|
||||
@@ -103,6 +121,6 @@ onMounted(async () => {
|
||||
});
|
||||
watch(() => props.featureCollection, () => updateData(true), { deep: true });
|
||||
watch(() => props.layers, () => updateData(false), { deep: true });
|
||||
watch(() => props.selectedId, () => updateData(false));
|
||||
watch(() => props.selectedId, () => updateData(true));
|
||||
onBeforeUnmount(() => { map?.remove(); map = null; });
|
||||
</script>
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
Files,
|
||||
FolderOpened,
|
||||
Guide,
|
||||
MagicStick,
|
||||
MapLocation,
|
||||
Monitor,
|
||||
Position,
|
||||
@@ -51,6 +52,7 @@ export const navigationGroups: NavigationGroup[] = [
|
||||
label: "研判",
|
||||
items: [
|
||||
{ path: "/analysis", label: "智能分析", description: "推理与规则", icon: Cpu },
|
||||
{ path: "/visualization-demo", label: "视频 AI 演示", description: "上传视频与本地导出", icon: MagicStick },
|
||||
{ path: "/alarms", label: "告警中心", description: "告警研判", icon: Bell }
|
||||
]
|
||||
},
|
||||
|
||||
@@ -19,6 +19,7 @@ const router = createRouter({
|
||||
{ path: "uav-operations", name: "uav-operations", meta: { title: "无人机运行" }, component: () => import("../views/uav-operations/UavOperationsView.vue") },
|
||||
{ path: "resources/:resourceId?", name: "resources", meta: { title: "数据资源" }, component: () => import("../views/resources/ResourcesView.vue") },
|
||||
{ path: "analysis/:entityType?/:entityId?", name: "analysis", meta: { title: "智能分析" }, component: () => import("../views/analysis/AnalysisView.vue") },
|
||||
{ path: "visualization-demo", name: "visualization-demo", meta: { title: "视频 AI 演示" }, component: () => import("../views/visualization-demo/VideoAiDemoView.vue") },
|
||||
{ path: "alarms/:alarmId?", name: "alarms", meta: { title: "告警中心" }, component: () => import("../views/alarms/AlarmsView.vue") },
|
||||
{ path: "workorders/:workorderId?", name: "workorders", meta: { title: "工单中心" }, component: () => import("../views/workorders/WorkordersView.vue") },
|
||||
{ path: "algorithm-assets/:entityType?/:entityId?", name: "algorithm-assets", meta: { title: "样本与模型" }, component: () => import("../views/algorithm-assets/AlgorithmAssetsView.vue") },
|
||||
|
||||
@@ -53,8 +53,19 @@ export async function analysisResults() {
|
||||
return data.data.results;
|
||||
}
|
||||
|
||||
export async function createAnalysisJob(payload: {
|
||||
task_id: string;
|
||||
resource_ids: string[];
|
||||
scene_set?: string[];
|
||||
analysis_mode?: string;
|
||||
priority?: string;
|
||||
}) {
|
||||
const { data } = await client.post("/analysis/jobs", payload);
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function runAnalysisJob(jobId: string) {
|
||||
const { data } = await client.post(`/analysis/jobs/${jobId}/run`, {});
|
||||
const { data } = await client.post(`/analysis/jobs/${jobId}/run`, {}, { timeout: 10 * 60 * 1000 });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
@@ -113,6 +124,11 @@ export async function serviceHealth() {
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function operationsHealth() {
|
||||
const { data } = await client.get("/operations/health", { timeout: 15000 });
|
||||
return data.data;
|
||||
}
|
||||
|
||||
export async function aiRuntimeStatus() {
|
||||
const { data } = await client.get("/operations/ai-runtime");
|
||||
return data.data;
|
||||
|
||||
@@ -0,0 +1,109 @@
|
||||
import axios from "axios";
|
||||
|
||||
const videoDemoBase = (import.meta.env.VITE_VISION_API_BASE || "/vision-api").replace(/\/$/, "");
|
||||
const client = axios.create({
|
||||
baseURL: `${videoDemoBase}/api/v1/video-demo`,
|
||||
timeout: 45000
|
||||
});
|
||||
|
||||
export type VideoDemoWarning = {
|
||||
code: string;
|
||||
message: string;
|
||||
model_group?: string;
|
||||
};
|
||||
|
||||
export type VideoDetection = {
|
||||
category: string;
|
||||
confidence: number;
|
||||
bbox: number[];
|
||||
model_group: string;
|
||||
model_version?: string;
|
||||
execution_mode?: string;
|
||||
};
|
||||
|
||||
export type VideoMaskRle = {
|
||||
encoding: "rle";
|
||||
width: number;
|
||||
height: number;
|
||||
counts: number[];
|
||||
};
|
||||
|
||||
export type VideoSegment = {
|
||||
category: string;
|
||||
confidence: number;
|
||||
polygon: number[][];
|
||||
mask?: VideoMaskRle;
|
||||
area_ratio?: number;
|
||||
model_group: string;
|
||||
model_version?: string;
|
||||
execution_mode?: string;
|
||||
};
|
||||
|
||||
export type FrameInferenceResponse = {
|
||||
session_id: string;
|
||||
timestamp_ms: number;
|
||||
frame_id: string;
|
||||
source: { width: number; height: number; reported_width?: number; reported_height?: number };
|
||||
inference: { width: number; height: number };
|
||||
scene?: { id: string; label: string; detection_model_version?: string; segmentation_model_version?: string };
|
||||
runtime: Record<string, number | string | undefined>;
|
||||
results: { detections: VideoDetection[]; segments: VideoSegment[] };
|
||||
warnings: VideoDemoWarning[];
|
||||
};
|
||||
|
||||
export type VideoExportJob = {
|
||||
run_id: string;
|
||||
status: "queued" | "running" | "succeeded" | "failed";
|
||||
output_dir?: string;
|
||||
status_url?: string;
|
||||
progress: {
|
||||
processed_frames: number;
|
||||
total_frames?: number | null;
|
||||
percent: number;
|
||||
elapsed_seconds?: number;
|
||||
eta_seconds?: number | null;
|
||||
};
|
||||
outputs: {
|
||||
annotated_video?: string | null;
|
||||
results_json?: string | null;
|
||||
results_jsonl?: string | null;
|
||||
metadata_json?: string | null;
|
||||
log?: string | null;
|
||||
};
|
||||
warnings: VideoDemoWarning[];
|
||||
error?: string | null;
|
||||
};
|
||||
|
||||
export type VideoExportCreateResponse = Pick<VideoExportJob, "run_id" | "status"> & {
|
||||
output_dir: string;
|
||||
status_url: string;
|
||||
};
|
||||
|
||||
export async function videoDemoCapabilities() {
|
||||
const { data } = await client.get("/capabilities", { timeout: 10000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function warmupVideoDemoModels(models: string[], modelVersions: Record<string, string | undefined> = {}) {
|
||||
const { data } = await client.post("/warmup", { models, model_versions: modelVersions }, { timeout: 120000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function inferVideoFrame(form: FormData): Promise<FrameInferenceResponse> {
|
||||
const { data } = await client.post("/infer-frame", form, { timeout: 45000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function createVideoExportJob(form: FormData): Promise<VideoExportCreateResponse> {
|
||||
const { data } = await client.post("/export-jobs", form, { timeout: 10 * 60 * 1000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export async function videoExportJob(runId: string): Promise<VideoExportJob> {
|
||||
const { data } = await client.get(`/export-jobs/${encodeURIComponent(runId)}`, { timeout: 10000 });
|
||||
return data;
|
||||
}
|
||||
|
||||
export function videoExportFileUrl(runId: string, fileName: string) {
|
||||
return `${videoDemoBase}/api/v1/video-demo/export-jobs/${encodeURIComponent(runId)}/files/${encodeURIComponent(fileName)}`;
|
||||
}
|
||||
@@ -17,14 +17,14 @@
|
||||
<el-table-column label="置信度" width="90"><template #default="scope">{{ Number(scope.row.confidence || 0).toFixed(2) }}</template></el-table-column>
|
||||
<el-table-column label="位置" min-width="150"><template #default="scope">{{ locationText(scope.row.location) }}</template></el-table-column>
|
||||
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="statusTag(scope.row.status)" effect="plain">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="工单" min-width="155"><template #default="scope"><span v-if="relatedWorkorder(scope.row.alarm_id)" class="entity-id">{{ relatedWorkorder(scope.row.alarm_id)?.workorder_id }}</span><span v-else>-</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="openDetail(scope.row)">证据链</el-button><el-button v-if="!scope.row.suppressed && !['confirmed','closed'].includes(scope.row.status)" link type="success" @click="decision(scope.row, 'confirm')">确认</el-button><el-button v-if="!scope.row.suppressed && scope.row.status !== 'closed'" link type="warning" @click="decision(scope.row, 'suppress')">抑制</el-button><el-button v-if="scope.row.suppressed" link type="primary" @click="decision(scope.row, 'reopen')">恢复</el-button></div></template></el-table-column>
|
||||
<el-table-column label="工单" min-width="155"><template #default="scope"><el-button v-if="relatedWorkorder(scope.row.alarm_id)" link type="primary" class="entity-id" @click.stop="openWorkorder(scope.row)">{{ relatedWorkorder(scope.row.alarm_id)?.workorder_id }}</el-button><span v-else>-</span></template></el-table-column>
|
||||
<el-table-column label="操作" width="230" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="openDetail(scope.row)">证据链</el-button><el-button v-if="canConfirm(scope.row)" link type="success" @click="decision(scope.row, 'confirm')">确认</el-button><el-button v-if="canSuppress(scope.row)" link type="warning" @click="decision(scope.row, 'suppress')">抑制</el-button><el-button v-if="scope.row.suppressed" link type="primary" @click="decision(scope.row, 'reopen')">恢复</el-button></div></template></el-table-column>
|
||||
</el-table><el-pagination v-model:current-page="page" v-model:page-size="pageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="[20, 50, 100]" :total="filteredRows.length" /></el-card>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="告警研判" size="min(760px, 94vw)">
|
||||
<el-skeleton v-if="detailLoading" :rows="10" animated />
|
||||
<AlarmEvidencePanel v-else-if="evidence.alarm" :evidence="evidence">
|
||||
<template #actions><div class="drawer-actions"><el-button type="primary" @click="decision(evidence.alarm, 'confirm')">确认有效</el-button><el-button type="warning" @click="decision(evidence.alarm, 'suppress')">抑制告警</el-button><el-button @click="router.push({ path: '/gis', query: { alarmId: evidence.alarm.alarm_id } })">地图定位</el-button><el-button v-if="evidence.workorders?.length" @click="router.push(`/workorders/${evidence.workorders[0].workorder_id}`)">查看工单</el-button></div></template>
|
||||
<template #actions><div class="drawer-actions"><el-button v-if="canConfirm(evidence.alarm)" type="primary" @click="decision(evidence.alarm, 'confirm')">确认有效</el-button><el-button v-if="canSuppress(evidence.alarm)" type="warning" @click="decision(evidence.alarm, 'suppress')">抑制告警</el-button><el-button v-if="evidence.alarm.suppressed" @click="decision(evidence.alarm, 'reopen')">恢复告警</el-button><el-button @click="openAlarmOnMap(evidence.alarm)">地图定位</el-button><el-button v-if="evidence.workorders?.length" @click="router.push(`/workorders/${evidence.workorders[0].workorder_id}`)">查看工单</el-button></div></template>
|
||||
</AlarmEvidencePanel>
|
||||
<el-empty v-else description="未获取到告警证据" />
|
||||
</el-drawer>
|
||||
@@ -59,6 +59,10 @@ const filteredRows = computed(() => rows.value.filter((row) => matchTab(row) &&
|
||||
const pagedRows = computed(() => filteredRows.value.slice((page.value - 1) * pageSize.value, page.value * pageSize.value));
|
||||
const metrics = computed(() => [{ label: "有效告警", value: rows.value.filter((row) => !row.suppressed).length, note: "全部有效告警" }, { label: "严重与高等级", value: rows.value.filter((row) => ["critical", "high"].includes(String(row.severity)) && !row.suppressed).length, note: "优先处理" }, { label: "待研判", value: tabCount("pending"), note: "需要人工确认" }, { label: "已转工单", value: tabCount("dispatched"), note: "进入现场处置" }, { label: "已抑制", value: tabCount("suppressed"), note: "保留抑制原因" }]);
|
||||
function relatedWorkorder(id: unknown) { return workorderRows.value.find((row) => String(row.alarm_id) === String(id)); }
|
||||
function canConfirm(row: Row) { return !row.suppressed && !relatedWorkorder(row.alarm_id) && !["confirmed", "closed"].includes(String(row.status)); }
|
||||
function canSuppress(row: Row) { return !row.suppressed && !relatedWorkorder(row.alarm_id) && row.status !== "closed"; }
|
||||
function openWorkorder(row: Row) { const workorder = relatedWorkorder(row.alarm_id); if (workorder) router.push(`/workorders/${workorder.workorder_id}`); }
|
||||
function openAlarmOnMap(row: Row) { const workorder = relatedWorkorder(row.alarm_id); router.push({ path: "/gis", query: { alarmId: String(row.alarm_id), taskId: String(row.task_id || ""), workorderId: workorder ? String(workorder.workorder_id) : undefined } }); }
|
||||
function matchesTab(row: Row, tab: string) { if (tab === "all") return true; if (tab === "pending") return !row.suppressed && ["pending", "detected"].includes(String(row.status)); if (tab === "confirmed") return row.status === "confirmed"; if (tab === "suppressed") return Boolean(row.suppressed); if (tab === "dispatched") return Boolean(relatedWorkorder(row.alarm_id)); return row.status === "closed"; }
|
||||
function matchTab(row: Row) { return matchesTab(row, activeTab.value); }
|
||||
function tabCount(tab: string) { return rows.value.filter((row) => matchesTab(row, tab)).length; }
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
<el-tabs v-model="activeTab" class="secondary-tabs">
|
||||
<el-tab-pane :label="`分析任务 ${jobRows.length}`" name="jobs">
|
||||
<div class="filter-bar"><el-input v-model="keyword" clearable placeholder="分析任务或巡检任务" :prefix-icon="Search" /><el-select v-model="statusFilter"><el-option label="全部状态" value="" /><el-option label="排队中" value="queued" /><el-option label="执行中" value="running" /><el-option label="已完成" value="completed" /><el-option label="失败" value="failed" /></el-select><span class="filter-spacer"></span><span>{{ filteredJobs.length }} 个分析任务</span></div>
|
||||
<el-card class="workspace-card" shadow="never"><el-table :data="pagedJobs" empty-text="暂无分析任务"><el-table-column prop="analysis_job_id" label="分析任务" min-width="170"><template #default="scope"><span class="entity-id">{{ scope.row.analysis_job_id }}</span></template></el-table-column><el-table-column prop="task_id" label="巡检任务" min-width="170" show-overflow-tooltip /><el-table-column prop="line_id" label="线路" width="105" /><el-table-column label="资源" width="85"><template #default="scope">{{ arrayCount(scope.row.resource_ids) }}</template></el-table-column><el-table-column label="场景" width="85"><template #default="scope">{{ arrayCount(scope.row.scene_set) }}</template></el-table-column><el-table-column prop="analysis_mode" label="模式" width="90" /><el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="statusTag(scope.row.status)" effect="plain">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column><el-table-column label="操作" width="100"><template #default="scope"><el-button link type="primary" :disabled="scope.row.status === 'running'" @click="rerun(scope.row)">重新执行</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="jobPage" v-model:page-size="jobPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="filteredJobs.length" /></el-card>
|
||||
<el-card class="workspace-card" shadow="never"><el-table :data="pagedJobs" empty-text="暂无分析任务"><el-table-column prop="analysis_job_id" label="分析任务" min-width="170"><template #default="scope"><span class="entity-id">{{ scope.row.analysis_job_id }}</span></template></el-table-column><el-table-column prop="task_id" label="巡检任务" min-width="170" show-overflow-tooltip /><el-table-column prop="line_id" label="线路" width="105" /><el-table-column label="资源" width="85"><template #default="scope">{{ arrayCount(scope.row.resource_ids) }}</template></el-table-column><el-table-column label="场景" width="85"><template #default="scope">{{ arrayCount(scope.row.scene_set) }}</template></el-table-column><el-table-column prop="analysis_mode" label="模式" width="90" /><el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="statusTag(scope.row.status)" effect="plain">{{ statusLabel(scope.row.status) }}</el-tag></template></el-table-column><el-table-column label="操作" width="110"><template #default="scope"><el-button link type="primary" :loading="runningJobId === String(scope.row.analysis_job_id)" :disabled="!canRunJob(scope.row)" @click="rerun(scope.row)">{{ analysisActionLabel(scope.row) }}</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="jobPage" v-model:page-size="jobPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="filteredJobs.length" /></el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane :label="`AI 结果 ${resultRows.length}`" name="results">
|
||||
@@ -23,7 +23,7 @@
|
||||
|
||||
<el-tab-pane :label="`失败与降级 ${failedJobs.length}`" name="failed">
|
||||
<el-empty v-if="failedJobs.length === 0" description="当前没有失败或降级的分析任务" />
|
||||
<el-card v-else class="workspace-card" shadow="never"><el-table :data="pagedFailedJobs"><el-table-column prop="analysis_job_id" label="分析任务" /><el-table-column prop="task_id" label="巡检任务" /><el-table-column prop="status" label="状态" /><el-table-column label="操作"><template #default="scope"><el-button link type="primary" @click="rerun(scope.row)">重新执行</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="failedPage" v-model:page-size="failedPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="failedJobs.length" /></el-card>
|
||||
<el-card v-else class="workspace-card" shadow="never"><el-table :data="pagedFailedJobs"><el-table-column prop="analysis_job_id" label="分析任务" min-width="170" /><el-table-column prop="task_id" label="巡检任务" min-width="170" /><el-table-column prop="status" label="状态" width="100" /><el-table-column label="失败原因" min-width="220" show-overflow-tooltip><template #default="scope">{{ failureReason(scope.row.summary) }}</template></el-table-column><el-table-column label="操作" width="110"><template #default="scope"><el-button link type="primary" :loading="runningJobId === String(scope.row.analysis_job_id)" :disabled="!canRunJob(scope.row)" @click="rerun(scope.row)">{{ analysisActionLabel(scope.row) }}</el-button></template></el-table-column></el-table><el-pagination v-model:current-page="failedPage" v-model:page-size="failedPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="failedJobs.length" /></el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
@@ -41,7 +41,7 @@ import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePaginati
|
||||
import { alarms, analysisJobs, analysisResults, resultEvidence, runAnalysisJob } from "../../services/api";
|
||||
import { safeObject, statusLabel, statusTag, type Row } from "../../types/demo-run";
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const jobRows = ref<Row[]>([]); const resultRows = ref<Row[]>([]); const ruleRows = ref<Row[]>([]);
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const jobRows = ref<Row[]>([]); const resultRows = ref<Row[]>([]); const ruleRows = ref<Row[]>([]); const runningJobId = ref("");
|
||||
const activeTab = ref(String(route.query.tab || (route.params.entityType === "results" ? "results" : "jobs"))); const keyword = ref(""); const statusFilter = ref(""); const sceneFilter = ref(""); const detailVisible = ref(false); const detailLoading = ref(false); const detail = ref<Row>({});
|
||||
const scenes = computed(() => [...new Set(resultRows.value.map((row) => String(row.scene)).filter(Boolean))]);
|
||||
const failedJobs = computed(() => jobRows.value.filter((row) => ["failed", "degraded"].includes(String(row.status))));
|
||||
@@ -57,8 +57,11 @@ function parseArray(value: unknown): unknown[] { try { return Array.isArray(valu
|
||||
function arrayCount(value: unknown) { return parseArray(value).length; }
|
||||
function ruleText(value: unknown) { return parseArray(value).join(" / ") || "业务规则命中"; }
|
||||
function locationText(value: unknown) { const location = safeObject(value); return [location.mileage, location.distance_to_track_m ? `${location.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; }
|
||||
function failureReason(value: unknown) { return String(safeObject(value).error || "未记录失败原因"); }
|
||||
function canRunJob(row: Row) { return ["queued", "failed"].includes(String(row.status)) && runningJobId.value !== String(row.analysis_job_id); }
|
||||
function analysisActionLabel(row: Row) { const status = String(row.status); if (status === "queued") return "开始分析"; if (status === "failed") return "重新执行"; if (status === "running") return "执行中"; if (status === "completed") return "已完成"; return "不可执行"; }
|
||||
async function load() { loading.value = true; try { [jobRows.value, resultRows.value, ruleRows.value] = await Promise.all([analysisJobs(), analysisResults(), alarms(true)]); const id = String(route.params.entityId || ""); if (route.params.entityType === "results" && id) { const row = resultRows.value.find((item) => String(item.ai_result_id) === id); if (row) inspectResult(row); } } finally { loading.value = false; } }
|
||||
async function rerun(row: Row) { await runAnalysisJob(String(row.analysis_job_id)); ElMessage.success("分析任务已重新执行"); await load(); }
|
||||
async function rerun(row: Row) { if (!canRunJob(row)) return; const jobId = String(row.analysis_job_id); runningJobId.value = jobId; try { const result = await runAnalysisJob(jobId); ElMessage.success(result.status === "completed" ? "分析任务已完成" : "分析任务正在执行"); await load(); } catch { ElMessage.error("分析执行未成功,请查看任务状态与失败原因"); await load(); if (jobRows.value.find((item) => String(item.analysis_job_id) === jobId)?.status === "failed") activeTab.value = "failed"; } finally { runningJobId.value = ""; } }
|
||||
async function inspectResult(row: Row) { detailVisible.value = true; detailLoading.value = true; router.replace({ path: `/analysis/results/${row.ai_result_id}`, query: { ...route.query, tab: "results" } }); try { detail.value = await resultEvidence(String(row.ai_result_id)); } finally { detailLoading.value = false; } }
|
||||
watch(detailVisible, (visible) => { if (!visible && route.params.entityId) router.replace({ path: "/analysis", query: { ...route.query, tab: "results" } }); });
|
||||
watch([keyword, statusFilter, sceneFilter], () => { resetJobPage(); resetResultPage(); });
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<el-button type="primary" :disabled="!selectedTaskId" @click="exportThematicMap">导出专题图</el-button>
|
||||
</PageHeader>
|
||||
|
||||
<el-alert v-if="deepLinkLabel" class="deep-link-alert" type="info" :closable="false" show-icon title="深链定位已生效" :description="deepLinkLabel" />
|
||||
|
||||
<div class="filter-bar">
|
||||
<el-select v-model="selectedTaskId" filterable placeholder="选择回放任务"><el-option v-for="task in taskRows" :key="task.task_id" :label="`${task.task_id} / ${task.line_id}`" :value="String(task.task_id)" /></el-select>
|
||||
<el-select v-model="exportFormat" class="export-format" placeholder="专题图格式"><el-option label="PDF 专题图" value="PDF" /><el-option label="PNG 图片" value="PNG" /><el-option label="HTML 可打印页" value="HTML" /><el-option label="GeoJSON 航迹" value="GEOJSON" /><el-option label="CSV 事件明细" value="CSV" /></el-select>
|
||||
@@ -18,10 +20,10 @@
|
||||
</div>
|
||||
|
||||
<section class="gis-page-layout">
|
||||
<OperationalGisMap :feature-collection="filteredFeatureCollection" :layers="layers" :selected-id="String(selected?.alarm_id || '')" @select="selectFeature" />
|
||||
<OperationalGisMap :feature-collection="filteredFeatureCollection" :layers="layers" :selected-id="selectedFeatureId" @select="selectFeature" />
|
||||
<aside class="gis-detail-panel">
|
||||
<template v-if="selected">
|
||||
<div class="gis-detail-head"><div><el-tag :type="selected.severity === 'critical' ? 'danger' : 'warning'" effect="dark">{{ severityLabel(selected.severity) }}</el-tag><h2>{{ selected.scene }}</h2></div><el-button text :icon="Close" @click="selected = null" /></div>
|
||||
<div class="gis-detail-head"><div><el-tag :type="selected.severity === 'critical' ? 'danger' : 'warning'" effect="dark">{{ severityLabel(selected.severity) }}</el-tag><h2>{{ selected.scene }}</h2></div><el-button text :icon="Close" @click="clearSelection" /></div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="告警编号"><span class="entity-id">{{ selected.alarm_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="类别">{{ selected.category }}</el-descriptions-item>
|
||||
@@ -32,6 +34,16 @@
|
||||
</el-descriptions>
|
||||
<div class="gis-detail-actions"><el-button type="primary" @click="router.push(`/alarms/${selected.alarm_id}`)">进入告警研判</el-button><el-button v-if="relatedWorkorder(selected.alarm_id)" @click="router.push(`/workorders/${relatedWorkorder(selected.alarm_id)?.workorder_id}`)">查看工单</el-button></div>
|
||||
</template>
|
||||
<template v-else-if="selectedObject">
|
||||
<div class="gis-detail-head"><div><el-tag type="success" effect="dark">巡检对象</el-tag><h2>{{ selectedObject.name }}</h2></div><el-button text :icon="Close" @click="clearSelection" /></div>
|
||||
<el-descriptions :column="1" border size="small">
|
||||
<el-descriptions-item label="对象编号"><span class="entity-id">{{ selectedObject.object_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="对象类型">{{ selectedObject.object_type }}</el-descriptions-item>
|
||||
<el-descriptions-item label="线路">{{ selectedObject.line_id || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="风险等级">{{ selectedObject.risk_level || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="状态">{{ selectedObject.status || '-' }}</el-descriptions-item>
|
||||
</el-descriptions>
|
||||
</template>
|
||||
<el-empty v-else description="点击地图告警点查看详情" :image-size="96" />
|
||||
</aside>
|
||||
</section>
|
||||
@@ -71,7 +83,7 @@
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref } from "vue";
|
||||
import { computed, onBeforeUnmount, onMounted, reactive, ref, watch } from "vue";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Close, Refresh } from "@element-plus/icons-vue";
|
||||
@@ -86,7 +98,7 @@ const loading = ref(false);
|
||||
const alarmRows = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const workorderRows = ref<Row[]>([]);
|
||||
const gisLayerRows = ref<Row[]>([]); const ruleSetRows = ref<Row[]>([]); const exemptionRows = ref<Row[]>([]); const geofenceRows = ref<Row[]>([]);
|
||||
const featureCollection = ref<Row>({ type: "FeatureCollection", features: [] });
|
||||
const selected = ref<Row | null>(null); const lineFilter = ref(""); const severityFilter = ref("");
|
||||
const selected = ref<Row | null>(null); const selectedObject = ref<Row | null>(null); const focusedTaskId = ref(""); const lineFilter = ref(""); const severityFilter = ref("");
|
||||
const spatialTab = ref("layers"); const spatialExplanation = ref<Row | null>(null);
|
||||
const selectedTaskId = ref(String(route.query.taskId || ""));
|
||||
const exportFormat = ref("PDF");
|
||||
@@ -95,7 +107,15 @@ const playing = ref(false); const playbackSpeed = ref(1);
|
||||
let playbackTimer: ReturnType<typeof setInterval> | null = null;
|
||||
const layers = reactive({ routes: true, rules: true, alarms: true });
|
||||
const lines = computed(() => [...new Set(taskRows.value.map((row) => String(row.line_id)).filter(Boolean))]);
|
||||
const filteredAlarms = computed(() => alarmRows.value.filter((row) => (!severityFilter.value || row.severity === severityFilter.value) && (!lineFilter.value || taskLine(row.task_id) === lineFilter.value)));
|
||||
const filteredAlarms = computed(() => alarmRows.value.filter((row) => (!focusedTaskId.value || String(row.task_id) === focusedTaskId.value) && (!severityFilter.value || row.severity === severityFilter.value) && (!lineFilter.value || taskLine(row.task_id) === lineFilter.value)));
|
||||
const selectedFeatureId = computed(() => String(selected.value?.alarm_id || selectedObject.value?.object_id || ""));
|
||||
const deepLinkLabel = computed(() => {
|
||||
if (route.query.workorderId) return `工单 ${route.query.workorderId} → 告警 ${selected.value?.alarm_id || "未找到空间点"}`;
|
||||
if (route.query.alarmId) return `告警 ${route.query.alarmId}${focusedTaskId.value ? ` · 任务 ${focusedTaskId.value}` : ""}`;
|
||||
if (route.query.objectId) return `巡检对象 ${route.query.objectId}`;
|
||||
if (route.query.taskId) return `巡检任务 ${route.query.taskId}`;
|
||||
return "";
|
||||
});
|
||||
const filteredFeatureCollection = computed(() => {
|
||||
const ids = new Set(filteredAlarms.value.map((row) => String(row.alarm_id)));
|
||||
const features: Row[] = (featureCollection.value.features || []).filter((feature: Row) => feature.properties?.domain_type !== "alarm" || ids.has(String(feature.properties?.alarm_id)));
|
||||
@@ -143,7 +163,35 @@ function taskLine(taskId: unknown) { return String(taskRows.value.find((row) =>
|
||||
function relatedWorkorder(alarmId: unknown) { return workorderRows.value.find((row) => String(row.alarm_id) === String(alarmId)); }
|
||||
function locationText(row: Row) { const location = safeObject(row.location); return [location.mileage, location.distance_to_track_m ? `距线路 ${location.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; }
|
||||
function parseArray(value: unknown): string[] { try { return Array.isArray(value) ? value.map(String) : JSON.parse(String(value || "[]")); } catch { return []; } }
|
||||
function selectFeature(properties: Row) { selected.value = alarmRows.value.find((row) => String(row.alarm_id) === String(properties.alarm_id)) || properties; }
|
||||
function selectFeature(properties: Row) {
|
||||
if (properties.domain_type === "inspection_object") {
|
||||
selected.value = null; selectedObject.value = properties; focusedTaskId.value = "";
|
||||
router.replace({ path: "/gis", query: { objectId: String(properties.object_id) } });
|
||||
return;
|
||||
}
|
||||
const alarm = alarmRows.value.find((row) => String(row.alarm_id) === String(properties.alarm_id)) || properties;
|
||||
selected.value = alarm; selectedObject.value = null; focusedTaskId.value = String(alarm.task_id || ""); selectedTaskId.value = focusedTaskId.value || selectedTaskId.value;
|
||||
const workorder = relatedWorkorder(alarm.alarm_id);
|
||||
router.replace({ path: "/gis", query: { alarmId: String(alarm.alarm_id), taskId: focusedTaskId.value || undefined, workorderId: workorder ? String(workorder.workorder_id) : undefined } });
|
||||
}
|
||||
function clearSelection() { selected.value = null; selectedObject.value = null; focusedTaskId.value = ""; router.replace({ path: "/gis" }); }
|
||||
function applyDeepLink() {
|
||||
const workorderId = String(route.query.workorderId || "");
|
||||
const linkedWorkorder = workorderRows.value.find((row) => String(row.workorder_id) === workorderId);
|
||||
const alarmId = String(route.query.alarmId || linkedWorkorder?.alarm_id || "");
|
||||
const alarm = alarmRows.value.find((row) => String(row.alarm_id) === alarmId);
|
||||
const taskId = String(route.query.taskId || linkedWorkorder?.task_id || alarm?.task_id || "");
|
||||
focusedTaskId.value = taskId;
|
||||
if (taskId) selectedTaskId.value = taskId;
|
||||
if (alarm) { selected.value = alarm; selectedObject.value = null; return; }
|
||||
const objectId = String(route.query.objectId || "");
|
||||
if (objectId) {
|
||||
const objectFeature = (featureCollection.value.features || []).find((feature: Row) => String(feature.id || feature.properties?.object_id) === objectId && feature.properties?.domain_type === "inspection_object");
|
||||
selected.value = null; selectedObject.value = objectFeature ? { ...objectFeature.properties, object_id: objectId } : { object_id: objectId, name: "未找到巡检对象" }; return;
|
||||
}
|
||||
selectedObject.value = null;
|
||||
selected.value = taskId ? alarmRows.value.find((row) => String(row.task_id) === taskId) || null : filteredAlarms.value[0] || null;
|
||||
}
|
||||
function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
|
||||
async function loadPlayback() {
|
||||
if (!selectedTaskId.value) return;
|
||||
@@ -199,7 +247,8 @@ async function explainSelected() {
|
||||
spatialTab.value = "rules";
|
||||
ElMessage.success(`空间解释完成:${result.decision}`);
|
||||
}
|
||||
async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value, gisLayerRows.value, ruleSetRows.value, exemptionRows.value, geofenceRows.value] = await Promise.all([alarms(), tasks(), workorders(), gisFeatures(), gisLayers(), spatialRuleSets(), spatialExemptions(), geofences()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); selected.value = filteredAlarms.value[0] || null; } finally { loading.value = false; } }
|
||||
async function load() { loading.value = true; try { [alarmRows.value, taskRows.value, workorderRows.value, featureCollection.value, gisLayerRows.value, ruleSetRows.value, exemptionRows.value, geofenceRows.value] = await Promise.all([alarms(true), tasks(), workorders(), gisFeatures(), gisLayers(), spatialRuleSets(), spatialExemptions(), geofences()]); if (!selectedTaskId.value) selectedTaskId.value = String(taskRows.value[0]?.task_id || ""); applyDeepLink(); } finally { loading.value = false; } }
|
||||
watch(() => route.query, applyDeepLink);
|
||||
onMounted(load);
|
||||
onBeforeUnmount(pausePlayback);
|
||||
</script>
|
||||
@@ -217,4 +266,5 @@ onBeforeUnmount(pausePlayback);
|
||||
.export-format { width: 150px; }
|
||||
.spatial-card { margin-top: 18px; }
|
||||
.spatial-explanation { margin-top: 14px; }
|
||||
.deep-link-alert { margin-bottom: 16px; }
|
||||
</style>
|
||||
|
||||
@@ -14,13 +14,19 @@
|
||||
<el-tabs v-model="activeTab" class="secondary-tabs">
|
||||
<el-tab-pane label="服务状态" name="services">
|
||||
<section class="service-grid">
|
||||
<div v-for="service in services" :key="service.name" class="service-card">
|
||||
<div v-for="service in services" :key="String(service.code)" class="service-card">
|
||||
<header>
|
||||
<el-icon><component :is="service.icon" /></el-icon>
|
||||
<el-icon><component :is="serviceIcon(service.code)" /></el-icon>
|
||||
<span><strong>{{ service.name }}</strong><small>{{ service.endpoint }}</small></span>
|
||||
<i class="status-dot" :class="{ danger: health.status !== 'UP' }"></i>
|
||||
<i class="status-dot" :class="serviceStatusClass(service.status)"></i>
|
||||
</header>
|
||||
<dl><div><dt>状态</dt><dd>{{ health.status === 'UP' ? '运行正常' : '连接异常' }}</dd></div><div><dt>检查方式</dt><dd>{{ service.check }}</dd></div></dl>
|
||||
<dl>
|
||||
<div><dt>状态</dt><dd>{{ service.status_text || statusText(service.status) }}</dd></div>
|
||||
<div><dt>响应</dt><dd>{{ latencyText(service.latency_ms) }}</dd></div>
|
||||
<div><dt>检查方式</dt><dd>{{ service.check }}</dd></div>
|
||||
<div><dt>检查时间</dt><dd>{{ timeOnly(service.checked_at) }}</dd></div>
|
||||
</dl>
|
||||
<p class="service-detail">{{ service.detail || "暂无探测详情" }}</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -125,7 +131,18 @@
|
||||
</el-tab-pane>
|
||||
|
||||
<el-tab-pane label="接口集成" name="integrations">
|
||||
<el-card class="workspace-card" shadow="never"><el-table :data="integrations"><el-table-column prop="name" label="集成系统" min-width="150" /><el-table-column prop="type" label="方式" width="110" /><el-table-column prop="endpoint" label="地址或通道" min-width="220" /><el-table-column label="状态" width="110"><template #default><el-tag type="success" effect="plain">已连接</el-tag></template></el-table-column><el-table-column prop="description" label="用途" min-width="200" /></el-table></el-card>
|
||||
<el-card class="workspace-card" shadow="never">
|
||||
<el-table :data="integrations" empty-text="尚未获得集成探测结果">
|
||||
<el-table-column prop="name" label="集成系统" min-width="150" />
|
||||
<el-table-column prop="type" label="方式" width="110" />
|
||||
<el-table-column prop="endpoint" label="地址或通道" min-width="220" />
|
||||
<el-table-column label="状态" width="120">
|
||||
<template #default="scope"><el-tag :type="integrationTagType(scope.row.status)" effect="plain">{{ scope.row.status_text || "未探测" }}</el-tag></template>
|
||||
</el-table-column>
|
||||
<el-table-column prop="description" label="用途" min-width="190" />
|
||||
<el-table-column prop="evidence" label="状态依据" min-width="260" />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</el-tab-pane>
|
||||
<el-tab-pane label="组织与责任" name="organization">
|
||||
<div class="page-grid two-columns">
|
||||
@@ -201,11 +218,11 @@ import {
|
||||
modelArtifactJob,
|
||||
modelArtifactRuntimeOverview,
|
||||
modelArtifactUninstallImpact,
|
||||
operationsHealth,
|
||||
organizations as loadOrganizations,
|
||||
overview,
|
||||
platformEvents,
|
||||
responsibilityRules,
|
||||
serviceHealth,
|
||||
uninstallModelArtifact,
|
||||
unloadModelArtifact,
|
||||
uploadModelArtifact
|
||||
@@ -215,7 +232,7 @@ import { safeObject, type Row } from "../../types/demo-run";
|
||||
const loading = ref(false);
|
||||
const activeTab = ref("services");
|
||||
const runtimeScope = ref<"active" | "all">("active");
|
||||
const health = ref<Row>({ status: "UNKNOWN" });
|
||||
const operations = ref<Row>({ status: "UNKNOWN", services: [], integrations: [] });
|
||||
const stats = ref<Row>({});
|
||||
const eventRows = ref<Row[]>([]);
|
||||
const organizationRows = ref<Row[]>([]);
|
||||
@@ -243,12 +260,14 @@ const { currentPage: auditPage, pageSize: auditPageSize, pagedItems: pagedAuditR
|
||||
const installerInfo = computed(() => safeObject(artifactOverview.value.installer));
|
||||
const installerStatus = computed(() => String(installerInfo.value.status || "unavailable"));
|
||||
const installerMessage = computed(() => String(installerInfo.value.message || "模型制品安装代理当前不可用,请检查 8103 服务。"));
|
||||
const artifactStoreAvailable = computed(() => Boolean(artifactOverview.value.artifact_store_available));
|
||||
const services = computed<Row[]>(() => asRows(operations.value.services));
|
||||
const integrations = computed<Row[]>(() => asRows(operations.value.integrations));
|
||||
const artifactStoreAvailable = computed(() => services.value.some(service => service.code === "minio" && service.status === "UP"));
|
||||
const readyCount = computed(() => artifactRows.value.filter(row => row.package_format === "builtin" || row.installation_status === "ready").length);
|
||||
const runtimeProfile = computed(() => String(installerInfo.value.profile || visionRuntime.value.profile || spatialRuntime.value.profile || "未配置"));
|
||||
const executionProvider = computed(() => String(visionRuntime.value.execution_provider || spatialRuntime.value.execution_provider || "-"));
|
||||
const metrics = computed(() => [
|
||||
{ label: "平台状态", value: health.value.status === "UP" ? "正常" : "异常", note: "Actuator 健康检查" },
|
||||
{ label: "服务状态", value: overallStatusText.value, note: checkedAtText.value },
|
||||
{ label: "模型制品", value: `${readyCount.value}/${artifactRows.value.length}`, note: runtimeProfile.value },
|
||||
{ label: "业务对象", value: Number(stats.value.tasks || 0) + Number(stats.value.resources || 0), note: "任务与资源" },
|
||||
{ label: "分析结果", value: stats.value.ai_results || 0, note: "累计推理结果" },
|
||||
@@ -262,9 +281,19 @@ const jobStatusType = computed(() => currentJob.value.status === "failed" ? "dan
|
||||
const jobStatusText = computed(() => ({ queued: "排队中", running: "执行中", completed: "已完成", failed: "失败" }[String(currentJob.value.status)] || "等待状态"));
|
||||
const jobTitle = computed(() => ({ install: "模型制品安装", load: "模型加载", unload: "运行资源释放", uninstall: "模型制品卸载" }[String(currentJob.value.job_type)] || "模型制品任务"));
|
||||
const latestJobMessage = computed(() => String(jobEvents.value[jobEvents.value.length - 1]?.message || "正在等待任务执行"));
|
||||
|
||||
const services = [{ name: "业务平台", endpoint: ":8080", check: "Actuator", icon: SetUp }, { name: "视觉推理服务", endpoint: ":8101", check: "HTTP Health", icon: Cpu }, { name: "点云分析服务", endpoint: ":8102", check: "HTTP Health", icon: DataLine }, { name: "模型安装代理", endpoint: ":8103", check: "HTTP Health", icon: Download }, { name: "PostGIS / Kafka / Redis / MinIO", endpoint: "基础设施", check: "容器健康检查", icon: Connection }];
|
||||
const integrations = [{ name: "无人机任务接口", type: "SDK/API", endpoint: "/api/v1/uav/callbacks/mission-status", description: "航线任务和飞行状态回调" }, { name: "多源数据接入", type: "REST", endpoint: "/api/v1/inspection/resources/complete", description: "资源清单与接入完成通知" }, { name: "工单系统", type: "REST", endpoint: "/api/v1/workorders/callbacks/status", description: "工单处置状态同步" }, { name: "业务事件总线", type: "Kafka", endpoint: "platform-events", description: "任务、告警、工单和样本事件" }];
|
||||
const overallStatusText = computed(() => ({ UP: "全部正常", DEGRADED: "部分异常", DOWN: "不可用" }[String(operations.value.status)] || "未检查"));
|
||||
const checkedAtText = computed(() => operations.value.checked_at ? `检查于 ${formatDateTime(operations.value.checked_at)}` : "等待首次检查");
|
||||
const serviceIconMap: Record<string, typeof SetUp> = {
|
||||
platform: SetUp,
|
||||
postgis: DataLine,
|
||||
redis: Connection,
|
||||
kafka: Connection,
|
||||
minio: Download,
|
||||
vision: Cpu,
|
||||
pointcloud: DataLine,
|
||||
"artifact-installer": Download,
|
||||
"uav-access": Connection
|
||||
};
|
||||
const configGroups = [{ title: "数据基础设施", description: "持久化、缓存、消息和对象存储", items: [{ label: "数据库", value: "PostgreSQL 16 + PostGIS 3.4" }, { label: "缓存", value: "Redis 7" }, { label: "消息", value: "Kafka 3.8.1" }, { label: "对象存储", value: "MinIO S3" }] }, { title: "算法服务", description: "视觉、空间分析与制品管理", items: [{ label: "视觉推理", value: "FastAPI :8101" }, { label: "点云分析", value: "FastAPI :8102" }, { label: "制品安装", value: "FastAPI :8103" }, { label: "模型交换", value: "ONNX Runtime / TensorRT 适配" }] }, { title: "平台与监控", description: "业务服务和可观测性", items: [{ label: "业务平台", value: "Spring Boot 3.3" }, { label: "前端", value: "Vue 3 + TypeScript" }, { label: "指标", value: "Prometheus :9090" }] }];
|
||||
|
||||
function openUpload(row: Row) {
|
||||
@@ -336,13 +365,41 @@ function environmentText(value: unknown) { return ({ local: "本地", developmen
|
||||
function payloadText(value: unknown) { const data = safeObject(value); return Object.entries(data).slice(0, 4).map(([key, item]) => `${key}: ${item}`).join(",") || "-"; }
|
||||
function asRows(value: unknown): Row[] { return Array.isArray(value) ? value.map(item => safeObject(item)) : []; }
|
||||
function formatDateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
|
||||
function timeOnly(value: unknown) { return value ? new Date(String(value)).toLocaleTimeString("zh-CN", { hour12: false }) : "-"; }
|
||||
function latencyText(value: unknown) { const latency = Number(value); return Number.isFinite(latency) ? `${latency} ms` : "-"; }
|
||||
function statusText(value: unknown) { return value === "UP" ? "运行正常" : value === "DOWN" ? "连接异常" : "未探测"; }
|
||||
function serviceStatusClass(value: unknown) { return { danger: value === "DOWN", warning: value !== "UP" && value !== "DOWN" }; }
|
||||
function integrationTagType(value: unknown) { return value === "AVAILABLE" ? "success" : value === "UNAVAILABLE" ? "danger" : "info"; }
|
||||
function serviceIcon(value: unknown) { return serviceIconMap[String(value)] || Connection; }
|
||||
function errorMessage(error: unknown) { const item = error as { response?: { data?: { message?: string; detail?: string; error?: string } }; message?: string }; return item.response?.data?.message || item.response?.data?.detail || item.response?.data?.error || item.message || "操作失败"; }
|
||||
function openPrometheus() { window.open("http://localhost:9090", "_blank", "noopener,noreferrer"); }
|
||||
function openPrometheus() { const target = new URL(window.location.href); target.port = "9090"; target.pathname = "/"; target.search = ""; target.hash = ""; window.open(target.toString(), "_blank", "noopener,noreferrer"); }
|
||||
async function load() {
|
||||
loading.value = true;
|
||||
try {
|
||||
const [healthData, statData, platformEventData, runtimeData, artifactData, organizationData, responsibilityData] = await Promise.all([serviceHealth(), overview(), platformEvents(), aiRuntimeStatus(), modelArtifactRuntimeOverview(), loadOrganizations(), responsibilityRules()]);
|
||||
health.value = healthData; stats.value = statData; eventRows.value = platformEventData; runtime.value = runtimeData; artifactOverview.value = artifactData; organizationRows.value = organizationData; responsibilityRows.value = responsibilityData;
|
||||
const loaders: Array<{ name: string; request: Promise<unknown>; apply: (value: unknown) => void }> = [
|
||||
{ name: "服务健康", request: operationsHealth(), apply: value => operations.value = safeObject(value) },
|
||||
{ name: "业务统计", request: overview(), apply: value => stats.value = safeObject(value) },
|
||||
{ name: "平台事件", request: platformEvents(), apply: value => eventRows.value = asRows(value) },
|
||||
{ name: "模型运行时", request: aiRuntimeStatus(), apply: value => runtime.value = safeObject(value) },
|
||||
{ name: "模型制品", request: modelArtifactRuntimeOverview(), apply: value => artifactOverview.value = safeObject(value) },
|
||||
{ name: "组织结构", request: loadOrganizations(), apply: value => organizationRows.value = asRows(value) },
|
||||
{ name: "责任规则", request: responsibilityRules(), apply: value => responsibilityRows.value = asRows(value) }
|
||||
];
|
||||
const results = await Promise.allSettled(loaders.map(item => item.request));
|
||||
const failures: string[] = [];
|
||||
results.forEach((result, index) => {
|
||||
if (result.status === "fulfilled") loaders[index].apply(result.value);
|
||||
else failures.push(loaders[index].name);
|
||||
});
|
||||
if (results[0].status === "rejected") {
|
||||
operations.value = {
|
||||
status: "DOWN",
|
||||
checked_at: new Date().toISOString(),
|
||||
services: [{ code: "platform", name: "业务平台", endpoint: "/api/v1/operations/health", check: "HTTP", status: "DOWN", status_text: "连接异常", detail: errorMessage(results[0].reason), checked_at: new Date().toISOString() }],
|
||||
integrations: []
|
||||
};
|
||||
}
|
||||
if (failures.length) ElMessage.warning(`刷新完成,但以下数据不可用:${failures.join("、")}`);
|
||||
} catch (error) { ElMessage.error(errorMessage(error)); }
|
||||
finally { loading.value = false; }
|
||||
}
|
||||
@@ -353,6 +410,7 @@ onBeforeUnmount(() => window.clearTimeout(jobTimer));
|
||||
|
||||
<style scoped>
|
||||
.runtime-panel { margin-top: 18px; border-top: 1px solid var(--el-border-color-light); padding-top: 16px; }
|
||||
.service-detail { margin: 10px 0 0; min-height: 34px; color: var(--el-text-color-secondary); font-size: 12px; line-height: 1.5; word-break: break-word; }
|
||||
.runtime-panel > header, .runtime-panel > footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.runtime-panel > header { margin-bottom: 12px; }
|
||||
.runtime-panel > header > div:first-child, .model-cell, .dialog-context { display: grid; gap: 3px; }
|
||||
|
||||
@@ -84,7 +84,7 @@
|
||||
<template #footer><el-button @click="objectDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveObject">创建</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="计划详情" size="720px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="巡检方式">{{ triggerTypeLabel(selectedPlan.trigger_type) }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="执行安排">{{ scheduleText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="下次/计划执行">{{ executionDateText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="优先级">{{ priorityLabel(selectedPlan.priority) }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions><div class="subsection-head drawer-section"><div><strong>执行预览</strong><span>使用当前调度配置计算后续窗口</span></div><el-button link @click="loadPlanRuntime(selectedPlan)">刷新</el-button></div><el-table :data="previewRunsRows" size="small" empty-text="暂无预览"><el-table-column prop="sequence" label="#" width="52" /><el-table-column label="窗口开始" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="窗口结束" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_end) }}</template></el-table-column><el-table-column label="生成键" min-width="170"><template #default="scope"><span class="entity-id">{{ String(scope.row.generation_key).slice(0, 18) }}</span></template></el-table-column></el-table><div class="subsection-head drawer-section"><div><strong>执行记录</strong><span>后台调度和手动触发均留痕</span></div></div><el-table :data="executionRows" size="small" empty-text="暂无执行记录"><el-table-column prop="status" label="状态" width="110" /><el-table-column label="窗口" min-width="185"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="任务" min-width="120"><template #default="scope">{{ parseArray(scope.row.generated_task_ids).length }} 个</template></el-table-column><el-table-column prop="trigger_source" label="来源" width="110" /></el-table></template></el-drawer>
|
||||
<el-drawer v-model="detailVisible" title="计划详情" size="720px"><template v-if="selectedPlan"><el-descriptions :column="1" border><el-descriptions-item label="计划编号"><span class="entity-id">{{ selectedPlan.plan_id }}</span></el-descriptions-item><el-descriptions-item label="巡检方式">{{ triggerTypeLabel(selectedPlan.trigger_type) }}</el-descriptions-item><el-descriptions-item label="业务类型">{{ planTypeLabel(selectedPlan.plan_type) }}</el-descriptions-item><el-descriptions-item label="执行安排">{{ scheduleText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="下次/计划执行">{{ executionDateText(selectedPlan) }}</el-descriptions-item><el-descriptions-item label="优先级">{{ priorityLabel(selectedPlan.priority) }}</el-descriptions-item><el-descriptions-item label="对象">{{ objectNames(selectedPlan.object_ids) }}</el-descriptions-item><el-descriptions-item label="场景">{{ parseArray(selectedPlan.scene_set).join('、') }}</el-descriptions-item><el-descriptions-item label="已生成任务">{{ selectedPlan.generated_task_count }}</el-descriptions-item></el-descriptions><div class="subsection-head drawer-section"><div><strong>执行预览</strong><span>{{ selectedPlan.trigger_type === 'MANUAL' ? '手动巡检仅预览本次执行窗口' : '使用当前调度配置计算后续窗口' }}</span></div><el-button link @click="loadPlanRuntime(selectedPlan)">刷新</el-button></div><el-table :data="previewRunsRows" size="small" empty-text="暂无预览"><el-table-column prop="sequence" label="#" width="52" /><el-table-column label="窗口开始" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="窗口结束" min-width="160"><template #default="scope">{{ formatDate(scope.row.scheduled_window_end) }}</template></el-table-column><el-table-column label="生成键" min-width="170"><template #default="scope"><span class="entity-id">{{ String(scope.row.generation_key).slice(0, 18) }}</span></template></el-table-column></el-table><div class="subsection-head drawer-section"><div><strong>执行记录</strong><span>后台调度和手动触发均留痕</span></div></div><el-table :data="executionRows" size="small" empty-text="暂无执行记录"><el-table-column prop="status" label="状态" width="110" /><el-table-column label="窗口" min-width="185"><template #default="scope">{{ formatDate(scope.row.scheduled_window_start) }}</template></el-table-column><el-table-column label="任务" min-width="120"><template #default="scope">{{ parseArray(scope.row.generated_task_ids).length }} 个</template></el-table-column><el-table-column prop="trigger_source" label="来源" width="110" /></el-table></template></el-drawer>
|
||||
|
||||
<el-drawer v-model="versionVisible" title="对象版本" size="620px">
|
||||
<template v-if="selectedObject"><h3>{{ selectedObject.name }}</h3><p class="entity-id">{{ selectedObject.object_id }}</p><el-table :data="objectVersionRows" size="small" empty-text="暂无版本记录"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="change_type" label="变更" width="95" /><el-table-column prop="change_reason" label="原因" min-width="140" /><el-table-column label="来源" min-width="150"><template #default="scope"><span class="entity-id">{{ scope.row.source_job_id || '-' }}</span></template></el-table-column><el-table-column label="时间" min-width="155"><template #default="scope">{{ formatDate(scope.row.created_at) }}</template></el-table-column></el-table></template>
|
||||
|
||||
@@ -35,7 +35,7 @@
|
||||
<div v-for="processor in processors" :key="processor.type"><span>{{ processor.type }}</span><strong>{{ typeCount(processor.key) }}</strong><small>{{ processor.steps }}</small></div>
|
||||
</div>
|
||||
<el-card class="workspace-card" shadow="never"><el-table :data="pagedPreprocessRows" empty-text="暂无预处理记录">
|
||||
<el-table-column prop="resource_id" label="资源" min-width="170"><template #default="scope"><span class="entity-id">{{ scope.row.resource_id }}</span></template></el-table-column><el-table-column label="处理链" min-width="280"><template #default="scope">{{ processorFor(scope.row.resource_type) }}</template></el-table-column><el-table-column prop="pipeline_version" label="流水线版本" min-width="145" /><el-table-column label="状态" width="110"><template #default="scope"><el-tag :type="scope.row.status === 'COMPLETED' ? 'success' : 'warning'" effect="plain">{{ scope.row.status === 'COMPLETED' ? '已完成' : '待处理' }}</el-tag></template></el-table-column><el-table-column label="质量" width="100"><template #default="scope">{{ scope.row.quality_status }}</template></el-table-column><el-table-column label="操作" width="120"><template #default="scope"><el-button link type="primary" :disabled="scope.row.status === 'COMPLETED'" @click="runPreprocess(scope.row)">执行预处理</el-button></template></el-table-column>
|
||||
<el-table-column prop="resource_id" label="资源" min-width="170"><template #default="scope"><span class="entity-id">{{ scope.row.resource_id }}</span></template></el-table-column><el-table-column label="处理链" min-width="280"><template #default="scope">{{ processorFor(scope.row.resource_type) }}</template></el-table-column><el-table-column prop="pipeline_version" label="流水线版本" min-width="145" /><el-table-column label="状态" width="110"><template #default="scope"><el-tag :type="scope.row.status === 'COMPLETED' ? 'success' : 'warning'" effect="plain">{{ scope.row.status === 'COMPLETED' ? '已完成' : '待处理' }}</el-tag></template></el-table-column><el-table-column label="质量" width="100"><template #default="scope">{{ scope.row.quality_status }}</template></el-table-column><el-table-column label="操作" width="220"><template #default="scope"><div class="list-actions"><el-button link type="primary" :disabled="scope.row.status === 'COMPLETED'" @click="runPreprocess(scope.row)">执行预处理</el-button><el-button link type="primary" :loading="Boolean(analysisRunning[String(scope.row.resource_id)])" :disabled="!canStartAnalysis(scope.row)" @click="startAnalysis(scope.row)">{{ analysisActionLabel(scope.row) }}</el-button></div></template></el-table-column>
|
||||
</el-table><el-pagination v-model:current-page="preprocessPage" v-model:page-size="preprocessPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="preprocessRows.length" /></el-card>
|
||||
</el-tab-pane>
|
||||
|
||||
@@ -65,12 +65,13 @@ import { ElMessage } from "element-plus";
|
||||
import { Grid, Histogram, Picture, Refresh, Search, Upload, UploadFilled } from "@element-plus/icons-vue";
|
||||
import PageHeader from "../../components/common/PageHeader.vue";
|
||||
import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination";
|
||||
import { preprocessJobs, resourcePreview, resources, runPreprocessJob, tasks, uploadInspectionFile } from "../../services/api";
|
||||
import { createAnalysisJob, preprocessJobs, resourcePreview, resources, runAnalysisJob, runPreprocessJob, tasks, uploadInspectionFile } from "../../services/api";
|
||||
import { safeObject, statusLabel, statusTag, type Row } from "../../types/demo-run";
|
||||
|
||||
const route = useRoute(); const router = useRouter(); const loading = ref(false); const rows = ref<Row[]>([]); const preprocessRows = ref<Row[]>([]); const taskRows = ref<Row[]>([]); const activeTab = ref(String(route.query.tab || "library")); const keyword = ref(""); const typeFilter = ref("");
|
||||
const detailVisible = ref(false); const detailLoading = ref(false); const detail = ref<Row>({});
|
||||
const uploadVisible = ref(false); const uploading = ref(false); const uploadProgress = ref(0); const uploadFile = ref<File | null>(null); const uploadForm = reactive({ task_id: "", resource_type: "image" });
|
||||
const analysisRunning = ref<Record<string, boolean>>({});
|
||||
const resourceTypes = [{ value: "image", label: "可见光" }, { value: "thermal", label: "红外" }, { value: "pointcloud", label: "点云" }, { value: "tif", label: "TIF/DEM" }];
|
||||
const processors = [{ key: "image", type: "可见光", steps: "畸变校正 / 质量检查 / 标准化" }, { key: "thermal", type: "红外", steps: "温度标定 / 坏点修复 / 辐射校正" }, { key: "pointcloud", type: "点云", steps: "去噪 / 配准 / 分割 / 坐标转换" }, { key: "tif", type: "TIF/DEM", steps: "投影校验 / 重采样 / 高程转换" }];
|
||||
const filteredRows = computed(() => rows.value.filter((row) => (!typeFilter.value || row.resource_type === typeFilter.value) && (!route.query.taskId || row.task_id === route.query.taskId) && (!keyword.value || `${row.resource_id} ${row.task_id} ${row.storage_url}`.toLowerCase().includes(keyword.value.toLowerCase()))));
|
||||
@@ -86,11 +87,15 @@ function fileName(path: unknown) { return String(path || "-").split("/").pop() |
|
||||
function metadataSummary(value: unknown) { const data = safeObject(value); return Object.entries(data).slice(0, 2).map(([key, item]) => `${key}: ${item}`).join(",") || "无扩展元数据"; }
|
||||
function processorFor(type: unknown) { return processors.find((item) => item.key === String(type))?.steps || "格式校验 / 标准化"; }
|
||||
function formatDateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
|
||||
function resourceAnalysisStatus(row: Row) { return String(rows.value.find((item) => item.resource_id === row.resource_id)?.analysis_status || "pending"); }
|
||||
function canStartAnalysis(row: Row) { return row.status === "COMPLETED" && row.quality_status === "PASSED" && resourceAnalysisStatus(row) !== "completed" && !analysisRunning.value[String(row.resource_id)]; }
|
||||
function analysisActionLabel(row: Row) { if (resourceAnalysisStatus(row) === "completed") return "已分析"; if (row.status !== "COMPLETED") return "等待预处理"; if (row.quality_status !== "PASSED") return "质量未通过"; return "创建并分析"; }
|
||||
async function load() { loading.value = true; try { [rows.value, preprocessRows.value, taskRows.value] = await Promise.all([resources(), preprocessJobs(), tasks()]); if (!uploadForm.task_id && taskRows.value.length) uploadForm.task_id = String(taskRows.value[0].task_id); const id = String(route.params.resourceId || ""); if (id) { const row = rows.value.find((item) => String(item.resource_id) === id); if (row) inspect(row); } } finally { loading.value = false; } }
|
||||
function selectFile(file: UploadFile) { uploadFile.value = file.raw || null; }
|
||||
function removeFile() { uploadFile.value = null; }
|
||||
async function uploadResource() { if (!uploadForm.task_id || !uploadFile.value) return ElMessage.warning("请选择巡检任务和资源文件"); uploading.value = true; uploadProgress.value = 0; try { const result = await uploadInspectionFile(uploadForm.task_id, uploadFile.value, uploadForm.resource_type, (value) => uploadProgress.value = value); await runPreprocessJob(String(result.preprocess_job_id)); ElMessage.success("资源已分片上传、摘要校验并完成基础预处理"); uploadVisible.value = false; uploadFile.value = null; activeTab.value = "preprocess"; await load(); } finally { uploading.value = false; } }
|
||||
async function runPreprocess(row: Row) { await runPreprocessJob(String(row.preprocess_job_id)); ElMessage.success("预处理与质量检查已完成"); await load(); }
|
||||
async function uploadResource() { if (!uploadForm.task_id || !uploadFile.value) return ElMessage.warning("请选择巡检任务和资源文件"); uploading.value = true; uploadProgress.value = 0; try { const result = await uploadInspectionFile(uploadForm.task_id, uploadFile.value, uploadForm.resource_type, (value) => uploadProgress.value = value); await runPreprocessJob(String(result.preprocess_job_id)); ElMessage.success("资源已完成上传与预处理,可在预处理记录中创建 AI 分析"); uploadVisible.value = false; uploadFile.value = null; activeTab.value = "preprocess"; await load(); } finally { uploading.value = false; } }
|
||||
async function runPreprocess(row: Row) { await runPreprocessJob(String(row.preprocess_job_id)); ElMessage.success("预处理与质量检查已完成,可继续创建 AI 分析"); await load(); }
|
||||
async function startAnalysis(row: Row) { if (!canStartAnalysis(row)) return; const resourceId = String(row.resource_id); let jobId = ""; analysisRunning.value[resourceId] = true; try { const job = await createAnalysisJob({ task_id: String(row.task_id), resource_ids: [resourceId], analysis_mode: "offline", priority: "normal" }); jobId = String(job.analysis_job_id); await runAnalysisJob(jobId); ElMessage.success("AI 分析已完成,正在打开结果"); await router.push({ path: "/analysis", query: { tab: "results", resourceId } }); } catch { if (jobId) { ElMessage.error("AI 分析失败,任务状态已记录,可在智能分析中重试"); await router.push({ path: "/analysis", query: { tab: "failed" } }); } else { ElMessage.error("分析任务创建失败,请稍后重试"); } } finally { analysisRunning.value[resourceId] = false; } }
|
||||
async function inspect(row: Row) { detailVisible.value = true; detailLoading.value = true; router.replace({ path: `/resources/${row.resource_id}`, query: route.query }); try { detail.value = await resourcePreview(String(row.resource_id)); } finally { detailLoading.value = false; } }
|
||||
watch(detailVisible, (visible) => { if (!visible && route.params.resourceId) router.replace({ path: "/resources", query: route.query }); });
|
||||
watch([keyword, typeFilter, () => route.query.taskId], resetResourcePage);
|
||||
|
||||
@@ -6,6 +6,16 @@
|
||||
<el-button type="primary" :icon="Plus" @click="createDialog = true">新建航线</el-button>
|
||||
</PageHeader>
|
||||
|
||||
<el-alert
|
||||
v-if="bindingTaskId"
|
||||
class="binding-alert"
|
||||
type="info"
|
||||
:closable="false"
|
||||
show-icon
|
||||
title="正在为巡检任务配置航线"
|
||||
:description="`任务 ${bindingTaskId}:请选择${bindingObjectId ? '当前巡检对象的' : ''}已发布航线并点击“绑定到任务”。`"
|
||||
/>
|
||||
|
||||
<section class="metric-strip">
|
||||
<div class="metric-card"><strong>{{ rows.length }}</strong><span>航线</span><small>统一航线台账</small></div>
|
||||
<div class="metric-card"><strong>{{ publishedCount }}</strong><span>已发布</span><small>允许任务下发</small></div>
|
||||
@@ -23,7 +33,7 @@
|
||||
<el-table-column label="校验" width="110"><template #default="scope"><el-tag :type="validation(scope.row).valid ? 'success' : 'danger'" effect="plain">{{ validation(scope.row).valid ? '通过' : '未通过' }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="状态" width="125"><template #default="scope"><el-tag :type="scope.row.status === 'PUBLISHED' ? 'success' : scope.row.status === 'APPROVED' ? 'warning' : 'info'" effect="plain">{{ routeStatusLabel(scope.row.status) }}</el-tag><small class="cell-subtext">{{ approvalLabel(scope.row.approval_status) }}</small></template></el-table-column>
|
||||
<el-table-column label="估算" width="120"><template #default="scope">{{ estimateSummary(scope.row) }}</template></el-table-column>
|
||||
<el-table-column label="操作" width="385" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button link type="primary" @click="validateRow(scope.row)">校验</el-button><el-button link type="primary" @click="estimateRow(scope.row)">估算</el-button><el-button v-if="['DRAFT','REJECTED'].includes(String(scope.row.approval_status || 'DRAFT'))" link type="warning" @click="submitRow(scope.row)">提交审批</el-button><el-button v-if="scope.row.status === 'APPROVED'" link type="success" @click="publishRow(scope.row)">发布</el-button><el-button link @click="newVersion(scope.row)">新版本</el-button><el-button link @click="inspect(scope.row)">详情</el-button></div></template></el-table-column>
|
||||
<el-table-column label="操作" :width="bindingTaskId ? 465 : 385" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="bindingTaskId && canBindToTask(scope.row)" link type="success" @click="bindToTask(scope.row)">绑定到任务</el-button><el-button link type="primary" @click="validateRow(scope.row)">校验</el-button><el-button link type="primary" @click="estimateRow(scope.row)">估算</el-button><el-button v-if="['DRAFT','REJECTED'].includes(String(scope.row.approval_status || 'DRAFT'))" link type="warning" @click="submitRow(scope.row)">提交审批</el-button><el-button v-if="scope.row.status === 'APPROVED'" link type="success" @click="publishRow(scope.row)">发布</el-button><el-button link @click="newVersion(scope.row)">新版本</el-button><el-button link @click="inspect(scope.row)">详情</el-button></div></template></el-table-column>
|
||||
</el-table><el-pagination v-model:current-page="page" v-model:page-size="pageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="filteredRows.length" /></el-card>
|
||||
|
||||
<el-dialog v-model="generateDialog" title="自动生成候选航线" width="min(700px, 94vw)" destroy-on-close>
|
||||
@@ -46,12 +56,48 @@
|
||||
<template #footer><el-button @click="createDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建并校验</el-button></template>
|
||||
</el-dialog>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="航线版本详情" size="760px"><template v-if="selected"><div class="route-visual"><div v-for="(point, index) in parseWaypoints(selected.waypoints)" :key="index" class="route-point" :style="pointStyle(point, index)"><span>{{ index + 1 }}</span></div><div class="route-axis"></div></div><el-descriptions :column="1" border><el-descriptions-item label="航线编号"><span class="entity-id">{{ selected.route_id }}</span></el-descriptions-item><el-descriptions-item label="工作版本">V{{ selected.version_no }} · {{ routeStatusLabel(selected.status) }} · {{ approvalLabel(selected.approval_status) }}</el-descriptions-item><el-descriptions-item label="巡检对象">{{ selected.object_name || '-' }}</el-descriptions-item><el-descriptions-item label="飞行参数">{{ jsonText(selected.flight_parameters) }}</el-descriptions-item><el-descriptions-item label="载荷动作">{{ jsonText(selected.payload_actions) }}</el-descriptions-item><el-descriptions-item label="估算结果">{{ routeEstimateText(selected.estimation_result) }}</el-descriptions-item><el-descriptions-item label="编辑锁">V{{ selected.version_lock ?? 0 }} · {{ selected.edited_by || selected.created_by || '-' }}</el-descriptions-item><el-descriptions-item label="SHA-256"><span class="entity-id">{{ selected.checksum || '-' }}</span></el-descriptions-item></el-descriptions><div class="validation-panel" :class="{ invalid: !validation(selected).valid }"><strong>{{ validation(selected).valid ? '航线校验通过' : '航线校验未通过' }}</strong><p v-for="message in [...(validation(selected).errors || []), ...(validation(selected).warnings || [])]" :key="message">{{ message }}</p><small v-if="!(validation(selected).errors?.length || validation(selected).warnings?.length)">航点数量、高度和速度均符合平台校验规则</small></div><div class="drawer-actions"><el-button type="primary" @click="estimateRow(selected)">重新估算</el-button><el-button :disabled="!['DRAFT','REJECTED'].includes(String(selected.status))" @click="saveWaypointRevision(selected)">保存编辑修订</el-button></div><div class="subsection-head version-head"><div><strong>不可变版本历史</strong><span>回滚将创建一个新草稿,不覆盖历史任务引用</span></div><el-button link @click="loadVersions(selected)">刷新</el-button></div><el-table :data="versionRows" size="small" empty-text="暂无版本"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="status" label="状态" width="105" /><el-table-column prop="approval_status" label="审批" width="120" /><el-table-column label="估算" min-width="120"><template #default="scope">{{ routeEstimateText(scope.row.estimation_result) }}</template></el-table-column><el-table-column label="摘要" min-width="150"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 16) }}</span></template></el-table-column><el-table-column label="操作" width="90"><template #default="scope"><el-button link type="warning" :disabled="scope.row.route_version_id === activeVersionId(selected)" @click="rollbackVersion(scope.row)">回滚</el-button></template></el-table-column></el-table><div class="subsection-head version-head"><div><strong>编辑审计</strong><span>航点和载荷动作修改记录</span></div></div><el-table :data="revisionRows" size="small" empty-text="暂无编辑修订"><el-table-column prop="revision_no" label="修订" width="70" /><el-table-column prop="editor_id" label="编辑人" width="120" /><el-table-column prop="change_summary" label="说明" min-width="160" /><el-table-column label="摘要" min-width="130"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 14) }}</span></template></el-table-column></el-table><div class="subsection-head version-head"><div><strong>通用动作 Schema</strong><span>{{ actionSchemas.length }} 项厂商无关动作约束</span></div></div><el-table :data="actionSchemas" size="small"><el-table-column prop="action_type" label="动作" width="150" /><el-table-column prop="device_type" label="设备" width="130" /><el-table-column label="状态" width="90"><template #default="scope"><el-tag size="small" type="success" effect="plain">{{ scope.row.status }}</el-tag></template></el-table-column></el-table></template></el-drawer>
|
||||
<el-drawer v-model="detailVisible" title="航线版本详情" size="820px">
|
||||
<template v-if="selected">
|
||||
<div class="route-visual"><div v-for="(point, index) in editingWaypoints" :key="index" class="route-point" :style="pointStyle(point, index)"><span>{{ index + 1 }}</span></div><div class="route-axis"></div></div>
|
||||
<el-descriptions :column="1" border>
|
||||
<el-descriptions-item label="航线编号"><span class="entity-id">{{ selected.route_id }}</span></el-descriptions-item>
|
||||
<el-descriptions-item label="工作版本">V{{ selected.version_no }} · {{ routeStatusLabel(selected.status) }} · {{ approvalLabel(selected.approval_status) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="巡检对象">{{ selected.object_name || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="载荷动作">{{ jsonText(selected.payload_actions) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="估算结果">{{ routeEstimateText(selected.estimation_result) }}</el-descriptions-item>
|
||||
<el-descriptions-item label="编辑锁">V{{ selected.version_lock ?? 0 }} · {{ selected.edited_by || selected.created_by || '-' }}</el-descriptions-item>
|
||||
<el-descriptions-item label="SHA-256"><span class="entity-id">{{ selected.checksum || '-' }}</span></el-descriptions-item>
|
||||
</el-descriptions>
|
||||
|
||||
<div class="subsection-head version-head"><div><strong>编辑航点与飞行参数</strong><span>{{ immutableSelected ? '保存时自动创建新草稿,不改写已发布版本' : '修改后将形成一条编辑审计记录' }}</span></div><el-button :icon="Plus" @click="addEditingWaypoint">增加航点</el-button></div>
|
||||
<div class="editing-flight-params"><label>默认速度 <el-input-number v-model="editingFlight.speed_mps" :min="1" :max="20" /></label><label>返航高度 <el-input-number v-model="editingFlight.rth_altitude_m" :min="20" :max="500" /></label></div>
|
||||
<el-table :data="editingWaypoints" border size="small">
|
||||
<el-table-column type="index" label="#" width="45" />
|
||||
<el-table-column label="经度" min-width="130"><template #default="scope"><el-input-number v-model="scope.row.longitude" :precision="6" :step="0.001" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column label="纬度" min-width="130"><template #default="scope"><el-input-number v-model="scope.row.latitude" :precision="6" :step="0.001" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column label="高度" min-width="105"><template #default="scope"><el-input-number v-model="scope.row.altitude_m" :min="10" :max="600" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column label="速度" min-width="100"><template #default="scope"><el-input-number v-model="scope.row.speed_mps" :min="1" :max="20" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column label="云台" min-width="100"><template #default="scope"><el-input-number v-model="scope.row.gimbal_pitch_deg" :min="-90" :max="30" controls-position="right" /></template></el-table-column>
|
||||
<el-table-column width="52"><template #default="scope"><el-button link type="danger" :disabled="editingWaypoints.length <= 2" :icon="Delete" @click="editingWaypoints.splice(scope.$index, 1)" /></template></el-table-column>
|
||||
</el-table>
|
||||
|
||||
<div class="validation-panel" :class="{ invalid: !validation(selected).valid }"><strong>{{ validation(selected).valid ? '航线校验通过' : '航线校验未通过' }}</strong><p v-for="message in [...(validation(selected).errors || []), ...(validation(selected).warnings || [])]" :key="message">{{ message }}</p><small v-if="!(validation(selected).errors?.length || validation(selected).warnings?.length)">航点数量、高度和速度均符合平台校验规则</small></div>
|
||||
<div class="drawer-actions"><el-button type="primary" @click="estimateRow(selected)">重新估算</el-button><el-button :loading="saving" :disabled="!editorDirty || editingWaypoints.length < 2" @click="saveWaypointRevision(selected)">{{ immutableSelected ? '另存为新修订' : '保存编辑修订' }}</el-button></div>
|
||||
<div class="subsection-head version-head"><div><strong>不可变版本历史</strong><span>回滚将创建一个新草稿,不覆盖历史任务引用</span></div><el-button link @click="loadVersions(selected)">刷新</el-button></div>
|
||||
<el-table :data="versionRows" size="small" empty-text="暂无版本"><el-table-column label="版本" width="70"><template #default="scope">V{{ scope.row.version_no }}</template></el-table-column><el-table-column prop="status" label="状态" width="105" /><el-table-column prop="approval_status" label="审批" width="120" /><el-table-column label="估算" min-width="120"><template #default="scope">{{ routeEstimateText(scope.row.estimation_result) }}</template></el-table-column><el-table-column label="摘要" min-width="150"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 16) }}</span></template></el-table-column><el-table-column label="操作" width="90"><template #default="scope"><el-button link type="warning" :disabled="scope.row.route_version_id === activeVersionId(selected)" @click="rollbackVersion(scope.row)">回滚</el-button></template></el-table-column></el-table>
|
||||
<div class="subsection-head version-head"><div><strong>编辑审计</strong><span>航点和载荷动作修改记录</span></div></div>
|
||||
<el-table :data="revisionRows" size="small" empty-text="暂无编辑修订"><el-table-column prop="revision_no" label="修订" width="70" /><el-table-column prop="editor_id" label="编辑人" width="120" /><el-table-column prop="change_summary" label="说明" min-width="160" /><el-table-column label="摘要" min-width="130"><template #default="scope"><span class="entity-id">{{ String(scope.row.checksum || '-').slice(0, 14) }}</span></template></el-table-column></el-table>
|
||||
<div class="subsection-head version-head"><div><strong>通用动作 Schema</strong><span>{{ actionSchemas.length }} 项厂商无关动作约束</span></div></div>
|
||||
<el-table :data="actionSchemas" size="small"><el-table-column prop="action_type" label="动作" width="150" /><el-table-column prop="device_type" label="设备" width="130" /><el-table-column label="状态" width="90"><template #default="scope"><el-tag size="small" type="success" effect="plain">{{ scope.row.status }}</el-tag></template></el-table-column></el-table>
|
||||
</template>
|
||||
</el-drawer>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, reactive, ref, watch } from "vue";
|
||||
import axios from "axios";
|
||||
import { useRoute, useRouter } from "vue-router";
|
||||
import { ElMessage } from "element-plus";
|
||||
import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue";
|
||||
import PageHeader from "../../components/common/PageHeader.vue";
|
||||
@@ -59,13 +105,21 @@ import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePaginati
|
||||
import { createInspectionRoute, createRouteVersion, estimateRouteVersion, executeWorkflowAction, generateCandidateRoute, inspectionObjects, inspectionRoutes, publishRouteVersion, rollbackRoute, routeActionSchemas, routeEditorRevisions, routeGenerationProfiles, routeVersions, updateRouteWaypoints, validateRouteVersion } from "../../services/api";
|
||||
import { safeObject, type Row } from "../../types/demo-run";
|
||||
|
||||
const route = useRoute(); const router = useRouter();
|
||||
const rows = ref<Row[]>([]); const objects = ref<Row[]>([]); const versionRows = ref<Row[]>([]); const generatorProfiles = ref<Row[]>([]); const revisionRows = ref<Row[]>([]); const actionSchemas = ref<Row[]>([]); const loading = ref(false); const saving = ref(false); const createDialog = ref(false); const generateDialog = ref(false); const detailVisible = ref(false); const selected = ref<Row | null>(null); const keyword = ref(""); const statusFilter = ref("");
|
||||
const editingWaypoints = ref<Row[]>([]); const editingFlight = ref<Row>({}); const editingPayloadActions = ref<Row[]>([]); const editorBaseline = ref("");
|
||||
const bindingTaskId = computed(() => String(route.query.taskId || ""));
|
||||
const bindingObjectId = computed(() => String(route.query.objectId || ""));
|
||||
const form = reactive({ name: "铁路沿线巡检航线", object_id: "object-line-demo", speed_mps: 7, rth_altitude_m: 100, waypoints: [{ longitude: 116.09, latitude: 39.095, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.125, latitude: 39.115, altitude_m: 80, gimbal_pitch_deg: -45 }, { longitude: 116.165, latitude: 39.105, altitude_m: 80, gimbal_pitch_deg: -45 }] });
|
||||
const generateForm = reactive({ object_id: "object-line-demo", generator_type: "", waypoint_count: 5, altitude_m: 80, speed_mps: 7, create_route: true });
|
||||
const filteredRows = computed(() => rows.value.filter((row) => (!statusFilter.value || row.status === statusFilter.value) && (!keyword.value || `${row.name} ${row.object_name} ${row.template_name}`.toLowerCase().includes(keyword.value.toLowerCase())))); const publishedCount = computed(() => rows.value.filter((row) => row.status === "PUBLISHED").length); const draftCount = computed(() => rows.value.filter((row) => row.status !== "PUBLISHED").length); const totalWaypoints = computed(() => rows.value.reduce((sum, row) => sum + parseWaypoints(row.waypoints).length, 0));
|
||||
const filteredRows = computed(() => rows.value.filter((row) => (!bindingObjectId.value || String(row.object_id) === bindingObjectId.value) && (!statusFilter.value || row.status === statusFilter.value) && (!keyword.value || `${row.name} ${row.object_name} ${row.template_name}`.toLowerCase().includes(keyword.value.toLowerCase())))); const publishedCount = computed(() => rows.value.filter((row) => row.status === "PUBLISHED" || row.route_status === "PUBLISHED").length); const draftCount = computed(() => rows.value.filter((row) => row.status !== "PUBLISHED").length); const totalWaypoints = computed(() => rows.value.reduce((sum, row) => sum + parseWaypoints(row.waypoints).length, 0));
|
||||
const generatorOptions = computed(() => generatorProfiles.value.filter((item) => !generateForm.object_id || item.object_type === objects.value.find((object) => object.object_id === generateForm.object_id)?.object_type));
|
||||
const immutableSelected = computed(() => !["DRAFT", "REJECTED"].includes(String(selected.value?.status || "")));
|
||||
const editorDirty = computed(() => Boolean(selected.value) && editorSnapshot() !== editorBaseline.value);
|
||||
const { currentPage: page, pageSize, pagedItems: pagedRows, resetPage } = usePagination(filteredRows);
|
||||
function parseWaypoints(value: unknown): Row[] { try { return Array.isArray(value) ? value as Row[] : JSON.parse(String(value || "[]")); } catch { return []; } }
|
||||
function editorSnapshot() { return JSON.stringify({ waypoints: editingWaypoints.value.map((point, index) => ({ ...point, sequence: index + 1 })), flight_parameters: editingFlight.value, payload_actions: editingPayloadActions.value }); }
|
||||
function resetEditor(row: Row) { editingWaypoints.value = parseWaypoints(row.waypoints).map((point, index) => ({ ...point, sequence: index + 1, speed_mps: Number(point.speed_mps ?? safeObject(row.flight_parameters).speed_mps ?? 6) })); editingFlight.value = { ...safeObject(row.flight_parameters) }; editingPayloadActions.value = parseWaypoints(row.payload_actions).map((item) => ({ ...item })); editorBaseline.value = editorSnapshot(); }
|
||||
function validation(row: Row): Row { return safeObject(row.validation_result); }
|
||||
function activeVersionId(row: Row) { return String(row.display_version_id || row.working_version_id || row.current_version_id || ""); }
|
||||
function routeStatusLabel(value: unknown) { return ({ DRAFT: "草稿", PENDING_APPROVAL: "审批中", APPROVED: "已审批", PUBLISHED: "已发布", REJECTED: "已驳回", RETIRED: "已退役" } as Record<string, string>)[String(value)] || String(value || "-"); }
|
||||
@@ -77,28 +131,61 @@ function routeEstimate(row: unknown) { return typeof row === "string" ? safeObje
|
||||
function routeEstimateText(value: unknown) { const data = routeEstimate(value); return data.estimated_minutes ? `${data.estimated_minutes} 分钟 · 电量 ${data.battery_percent}% · ${data.storage_mb} MB` : "待估算"; }
|
||||
function estimateSummary(row: Row) { return routeEstimateText(row.estimation_result); }
|
||||
function addWaypoint() { const previous = form.waypoints.at(-1) || { longitude: 116.1, latitude: 39.1, altitude_m: 80, gimbal_pitch_deg: -45 }; form.waypoints.push({ ...previous, longitude: previous.longitude + 0.005, latitude: previous.latitude + 0.003 }); }
|
||||
async function inspect(row: Row) { selected.value = row; detailVisible.value = true; await Promise.all([loadVersions(row), loadRevisionRows(row)]); }
|
||||
function pointStyle(_point: Row, index: number) { const count = Math.max(2, parseWaypoints(selected.value?.waypoints).length); return { left: `${10 + index * 80 / (count - 1)}%`, top: `${60 - Math.sin(index * 1.4) * 25}%` }; }
|
||||
function addEditingWaypoint() { const previous = editingWaypoints.value.at(-1) || { longitude: 116.1, latitude: 39.1, altitude_m: 80, speed_mps: 6, gimbal_pitch_deg: -45 }; editingWaypoints.value.push({ ...previous, sequence: editingWaypoints.value.length + 1, longitude: Number(previous.longitude) + 0.005, latitude: Number(previous.latitude) + 0.003 }); }
|
||||
async function inspect(row: Row) { selected.value = row; resetEditor(row); detailVisible.value = true; await Promise.all([loadVersions(row), loadRevisionRows(row)]); }
|
||||
function pointStyle(_point: Row, index: number) { const count = Math.max(2, editingWaypoints.value.length); return { left: `${10 + index * 80 / (count - 1)}%`, top: `${60 - Math.sin(index * 1.4) * 25}%` }; }
|
||||
async function load() { loading.value = true; try { [rows.value, objects.value, generatorProfiles.value, actionSchemas.value] = await Promise.all([inspectionRoutes(), inspectionObjects(), routeGenerationProfiles(), routeActionSchemas()]); if (!generateForm.generator_type && generatorProfiles.value.length) generateForm.generator_type = String(generatorProfiles.value[0].generator_type); } finally { loading.value = false; } }
|
||||
async function save() { if (!form.name || form.waypoints.length < 2) return ElMessage.warning("请填写航线名称并配置至少两个航点"); saving.value = true; try { await createInspectionRoute({ name: form.name, object_id: form.object_id, template_id: "template-railway", waypoints: form.waypoints.map((point, index) => ({ ...point, sequence: index + 1, speed_mps: form.speed_mps, actions: ["TAKE_PHOTO"] })), flight_parameters: { speed_mps: form.speed_mps, rth_altitude_m: form.rth_altitude_m }, payload_actions: [{ type: "TAKE_PHOTO", interval_s: 2 }], created_by: "user-dispatcher" }); createDialog.value = false; ElMessage.success("航线已创建并完成校验"); await load(); } finally { saving.value = false; } }
|
||||
async function validateRow(row: Row) { const result = await validateRouteVersion(activeVersionId(row)); ElMessage[result.valid ? "success" : "error"](result.valid ? "航线校验通过" : result.errors.join(";")); await load(); }
|
||||
async function estimateRow(row: Row) { const result = await estimateRouteVersion(activeVersionId(row), { created_by: "route-estimator" }); ElMessage[result.warnings?.length ? "warning" : "success"](`估算完成:${result.estimated_minutes} 分钟,电量 ${result.battery_percent}%`); await load(); if (selected.value?.route_id === row.route_id) selected.value = rows.value.find((item) => item.route_id === row.route_id) || selected.value; }
|
||||
async function submitRow(row: Row) { const action = String(row.approval_status) === "REJECTED" ? "RESUBMIT" : "SUBMIT"; await executeWorkflowAction("ROUTE_VERSION", activeVersionId(row), action as "SUBMIT" | "RESUBMIT", "航线校验完成,提交发布审批"); ElMessage.success("航线已进入审批队列"); await load(); }
|
||||
async function publishRow(row: Row) { try { await publishRouteVersion(activeVersionId(row)); ElMessage.success("航线版本已发布,历史版本已保留"); await load(); } catch { /* global error */ } }
|
||||
async function newVersion(row: Row) { const result = await createRouteVersion(String(row.route_id), { source_version_id: activeVersionId(row), change_summary: "基于当前版本创建编辑草稿", created_by: "航线管理员" }); ElMessage.success(`已创建 V${result.version_no} 草稿`); await load(); }
|
||||
async function newVersion(row: Row) { const result = await createRouteVersion(String(row.route_id), { source_version_id: activeVersionId(row), change_summary: "基于当前版本创建编辑草稿", created_by: "航线管理员" }); ElMessage.success(`已创建 V${result.version_no} 草稿`); await load(); const refreshed = rows.value.find((item) => item.route_id === row.route_id); if (refreshed && detailVisible.value) await inspect(refreshed); }
|
||||
async function loadVersions(row: Row) { versionRows.value = await routeVersions(String(row.route_id)); }
|
||||
async function loadRevisionRows(row: Row) { revisionRows.value = await routeEditorRevisions(activeVersionId(row)); }
|
||||
async function rollbackVersion(version: Row) { if (!selected.value) return; const result = await rollbackRoute(String(selected.value.route_id), String(version.route_version_id), `回滚至 V${version.version_no}`); ElMessage.success(`已创建回滚草稿 V${result.version_no},请重新校验和审批`); await load(); const refreshed = rows.value.find((row) => row.route_id === selected.value?.route_id); if (refreshed) { selected.value = refreshed; await loadVersions(refreshed); } }
|
||||
async function generateRouteFromObject() { if (!generateForm.object_id) return ElMessage.warning("请选择巡检对象"); saving.value = true; try { const result = await generateCandidateRoute({ object_id: generateForm.object_id, generator_type: generateForm.generator_type || undefined, create_route: generateForm.create_route, route_name: `${objects.value.find((item) => item.object_id === generateForm.object_id)?.name || "对象"} 候选航线`, parameters: { waypoint_count: generateForm.waypoint_count, altitude_m: generateForm.altitude_m, speed_mps: generateForm.speed_mps }, created_by: "route-generator" }); ElMessage.success(generateForm.create_route ? `已创建候选航线 ${result.created_route?.route_id}` : `已生成 ${result.waypoints?.length || 0} 个候选航点`); generateDialog.value = false; await load(); } finally { saving.value = false; } }
|
||||
async function saveWaypointRevision(row: Row) { const waypoints = parseWaypoints(row.waypoints).map((point, index) => ({ ...point, sequence: index + 1 })); const result = await updateRouteWaypoints(activeVersionId(row), { waypoints, flight_parameters: safeObject(row.flight_parameters), payload_actions: parseWaypoints(row.payload_actions), change_summary: "前端地图编辑器保存航点修订", editor_id: "route-editor", expected_version_lock: Number(row.version_lock || 0) }); ElMessage.success(`已保存编辑修订 V${result.version_lock}`); await load(); const refreshed = rows.value.find((item) => item.route_id === row.route_id); if (refreshed) { selected.value = refreshed; await Promise.all([loadVersions(refreshed), loadRevisionRows(refreshed)]); } }
|
||||
async function saveWaypointRevision(row: Row) {
|
||||
if (!editorDirty.value) return ElMessage.warning("请先修改至少一个航点或飞行参数");
|
||||
if (editingWaypoints.value.length < 2) return ElMessage.warning("航线至少需要两个航点");
|
||||
saving.value = true;
|
||||
try {
|
||||
let targetVersionId = activeVersionId(row); let expectedVersionLock = Number(row.version_lock || 0); let createdVersionNo: number | undefined;
|
||||
if (immutableSelected.value) {
|
||||
const created = await createRouteVersion(String(row.route_id), { source_version_id: activeVersionId(row), change_summary: `基于已发布 V${row.version_no} 编辑航点`, created_by: "航线管理员" });
|
||||
targetVersionId = String(created.route_version_id); expectedVersionLock = 0; createdVersionNo = Number(created.version_no);
|
||||
}
|
||||
const result = await updateRouteWaypoints(targetVersionId, { waypoints: editingWaypoints.value.map((point, index) => ({ ...point, sequence: index + 1 })), flight_parameters: { ...editingFlight.value }, payload_actions: editingPayloadActions.value, change_summary: immutableSelected.value ? "已发布版本航点编辑另存为新修订" : "航点与飞行参数编辑修订", editor_id: "route-editor", expected_version_lock: expectedVersionLock });
|
||||
ElMessage.success(createdVersionNo ? `已创建并保存 V${createdVersionNo} 编辑草稿` : `已保存编辑修订,编辑锁 V${result.version_lock}`);
|
||||
await load(); const refreshed = rows.value.find((item) => item.route_id === row.route_id); if (refreshed) await inspect(refreshed);
|
||||
} finally { saving.value = false; }
|
||||
}
|
||||
function canBindToTask(row: Row) { return Boolean(bindingTaskId.value) && String(row.route_status || row.status) === "PUBLISHED" && (!bindingObjectId.value || String(row.object_id) === bindingObjectId.value); }
|
||||
async function bindToTask(row: Row) {
|
||||
if (!canBindToTask(row)) return ElMessage.warning("请选择与任务巡检对象一致的已发布航线");
|
||||
saving.value = true;
|
||||
try {
|
||||
const response = await axios.put(`/api/v1/inspection/tasks/${encodeURIComponent(bindingTaskId.value)}/route`, { route_id: String(row.route_id), expected_object_id: bindingObjectId.value || undefined, bound_by: "user-dispatcher" });
|
||||
ElMessage.success(`已将航线 ${row.name || row.route_id} 绑定到任务`);
|
||||
await router.push({ path: `/tasks/${bindingTaskId.value}`, query: { routeBound: String(response.data?.data?.route_id || row.route_id) } });
|
||||
} catch (error: any) {
|
||||
ElMessage.error(error?.response?.data?.message || "航线绑定失败,请刷新后重试");
|
||||
} finally { saving.value = false; }
|
||||
}
|
||||
watch([keyword, statusFilter], resetPage);
|
||||
watch(() => generateForm.object_id, () => {
|
||||
const first = generatorOptions.value[0];
|
||||
if (first) generateForm.generator_type = String(first.generator_type);
|
||||
});
|
||||
onMounted(load);
|
||||
onMounted(async () => {
|
||||
if (bindingObjectId.value) { form.object_id = bindingObjectId.value; generateForm.object_id = bindingObjectId.value; }
|
||||
await load();
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.version-head { margin-top: 24px; }
|
||||
.binding-alert { margin-bottom: 16px; }
|
||||
.editing-flight-params { display: flex; gap: 24px; align-items: center; margin: 12px 0; }
|
||||
.editing-flight-params label { display: flex; gap: 8px; align-items: center; color: var(--el-text-color-regular); }
|
||||
</style>
|
||||
|
||||
@@ -343,7 +343,10 @@ function startManualInspection() { router.push({ path: "/planning", query: { cre
|
||||
function openDispatchWorkspace(row: Row) { router.push({ path: "/uav-operations", query: { tab: "missions", dispatchTaskId: String(row.task_id) } }); }
|
||||
function openMission(row: Row) { router.push({ path: "/uav-operations", query: { tab: "missions", missionId: String(row.mission_id) } }); }
|
||||
function openResources(row: Row) { router.push({ path: "/resources", query: { taskId: String(row.task_id) } }); }
|
||||
function openMap(row: Row) { router.push({ path: "/gis", query: { taskId: String(row.task_id) } }); }
|
||||
function openMap(row: Row) {
|
||||
const objectId = parseIds(row.object_scope)[0];
|
||||
router.push({ path: "/gis", query: objectId ? { taskId: String(row.task_id), objectId } : { taskId: String(row.task_id) } });
|
||||
}
|
||||
function configureRoute(row: Row) {
|
||||
const objectId = parseIds(row.object_scope)[0];
|
||||
router.push({ path: "/routes", query: objectId ? { objectId, taskId: String(row.task_id) } : { taskId: String(row.task_id) } });
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<el-table-column prop="vendor_code" label="厂商" width="95" />
|
||||
<el-table-column label="进度" min-width="140"><template #default="scope"><el-progress :percentage="scope.row.progress" :stroke-width="8" /></template></el-table-column>
|
||||
<el-table-column label="状态" width="105"><template #default="scope"><el-tag :type="missionTag(scope.row.status)" effect="plain">{{ missionLabel(scope.row.status) }}</el-tag></template></el-table-column>
|
||||
<el-table-column label="操作" width="330" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.status === 'DISPATCHED' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'MISSION_START')">开始</el-button><el-button v-if="scope.row.status === 'FLYING'" link type="warning" @click="command(scope.row, 'MISSION_PAUSE')">暂停</el-button><el-button v-if="scope.row.status === 'PAUSED'" link type="primary" @click="command(scope.row, 'MISSION_RESUME')">恢复</el-button><el-button v-if="scope.row.status === 'FLYING' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'SIMULATE_PROGRESS')">推进</el-button><el-button v-if="['FLYING','PAUSED'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'RETURN_HOME')">返航</el-button><el-button v-if="['DISPATCHED','PREPARING'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'MISSION_CANCEL')">取消</el-button><el-button link @click="inspectMission(scope.row)">遥测</el-button></div></template></el-table-column>
|
||||
<el-table-column label="操作" width="350" fixed="right"><template #default="scope"><div class="list-actions" @click.stop><el-button v-if="scope.row.status === 'DISPATCHED' && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'MISSION_START')">开始</el-button><el-button v-if="scope.row.status === 'FLYING'" link type="warning" @click="command(scope.row, 'MISSION_PAUSE')">暂停</el-button><el-button v-if="scope.row.status === 'PAUSED'" link type="primary" @click="command(scope.row, 'MISSION_RESUME')">恢复</el-button><el-button v-if="['FLYING','RETURNING'].includes(scope.row.status) && scope.row.vendor_code === 'SIMULATOR'" link type="primary" @click="command(scope.row, 'SIMULATE_PROGRESS')">{{ scope.row.status === 'RETURNING' ? '完成返航' : '推进' }}</el-button><el-button v-if="['FLYING','PAUSED'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'RETURN_HOME')">返航</el-button><el-button v-if="['DISPATCHED','PREPARING'].includes(scope.row.status)" link type="danger" @click="command(scope.row, 'MISSION_CANCEL')">取消</el-button><el-button link @click="inspectMission(scope.row)">遥测</el-button></div></template></el-table-column>
|
||||
</el-table><el-pagination v-model:current-page="missionPage" v-model:page-size="missionPageSize" class="table-pagination" background layout="total, sizes, prev, pager, next" :page-sizes="DEFAULT_PAGE_SIZES" :total="missions.length" /></el-card>
|
||||
</el-tab-pane>
|
||||
</el-tabs>
|
||||
|
||||
@@ -0,0 +1,635 @@
|
||||
<template>
|
||||
<div class="video-demo-page">
|
||||
<PageHeader title="视频 AI 可视化演示" description="本地视频播放、实时检测分割、完整结果视频导出">
|
||||
<input ref="fileInput" class="hidden-file-input" type="file" accept="video/*" @change="onVideoSelected" />
|
||||
<el-button :icon="UploadFilled" type="primary" @click="fileInput?.click()">上传视频</el-button>
|
||||
<el-button :icon="Refresh" :loading="capabilityLoading" @click="loadCapabilities">刷新运行时</el-button>
|
||||
<el-button :icon="Cpu" :loading="warming" @click="warmupModels">模型预热</el-button>
|
||||
</PageHeader>
|
||||
|
||||
<section class="video-demo-layout">
|
||||
<main class="video-workspace">
|
||||
<div ref="stageRef" class="video-stage">
|
||||
<video
|
||||
ref="videoRef"
|
||||
:src="videoUrl"
|
||||
controls
|
||||
playsinline
|
||||
@loadedmetadata="onVideoReady"
|
||||
@play="startFrameLoop"
|
||||
@pause="stopFrameLoop"
|
||||
@seeked="clearOverlay"
|
||||
></video>
|
||||
<canvas ref="overlayRef" class="video-overlay"></canvas>
|
||||
<div v-if="!videoUrl" class="video-empty">
|
||||
<el-icon><VideoPlay /></el-icon>
|
||||
<strong>等待视频文件</strong>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<section class="video-result-strip">
|
||||
<div><span>当前时间</span><strong>{{ currentTimeText }}</strong></div>
|
||||
<div><span>检测目标</span><strong>{{ latestResult?.results.detections.length || 0 }}</strong></div>
|
||||
<div><span>分割区域</span><strong>{{ latestResult?.results.segments.length || 0 }}</strong></div>
|
||||
<div><span>端到端延迟</span><strong>{{ latencyText }}</strong></div>
|
||||
<div><span>Provider</span><strong>{{ providerText }}</strong></div>
|
||||
</section>
|
||||
|
||||
<el-card class="workspace-card result-card" shadow="never">
|
||||
<template #header>
|
||||
<div class="card-heading">
|
||||
<strong>当前帧结果</strong>
|
||||
<span>{{ realtimeWarnings.length ? `${realtimeWarnings.length} 条提示` : "实时叠加" }}</span>
|
||||
</div>
|
||||
</template>
|
||||
<el-alert v-for="item in realtimeWarnings" :key="`${item.code}-${item.model_group}`" :title="item.message" type="warning" show-icon :closable="false" />
|
||||
<el-table :data="resultRows" size="small" max-height="260" empty-text="暂无结果">
|
||||
<el-table-column prop="type" label="类型" width="86" />
|
||||
<el-table-column prop="category" label="类别" min-width="130" show-overflow-tooltip />
|
||||
<el-table-column prop="confidence" label="置信度" width="90" />
|
||||
<el-table-column prop="model" label="模型" min-width="150" show-overflow-tooltip />
|
||||
</el-table>
|
||||
</el-card>
|
||||
</main>
|
||||
|
||||
<aside class="video-control-panel">
|
||||
<el-card class="workspace-card" shadow="never">
|
||||
<template #header><div class="card-heading"><strong>推理控制</strong><span>{{ videoFile?.name || "未选择视频" }}</span></div></template>
|
||||
<div class="control-stack">
|
||||
<label class="switch-row"><span><strong>目标检测</strong><small>检测框、类别、置信度</small></span><el-switch v-model="detectEnabled" @change="onModeChanged" /></label>
|
||||
<label class="switch-row"><span><strong>图像分割</strong><small>掩膜、轮廓、面积占比</small></span><el-switch v-model="segmentEnabled" @change="onModeChanged" /></label>
|
||||
<div class="form-row">
|
||||
<span>检测场景</span>
|
||||
<el-select v-model="selectedDetectionScene" @change="onSceneChanged">
|
||||
<el-option v-for="scene in detectionScenes" :key="scene.id" :label="scene.label" :value="scene.id" />
|
||||
</el-select>
|
||||
</div>
|
||||
<div class="slider-field"><span>检测阈值 {{ confidenceThreshold.toFixed(2) }}</span><el-slider v-model="confidenceThreshold" :min="0.05" :max="0.95" :step="0.05" /></div>
|
||||
<div class="slider-field"><span>分割阈值 {{ maskThreshold.toFixed(2) }}</span><el-slider v-model="maskThreshold" :min="0.1" :max="0.9" :step="0.05" /></div>
|
||||
<div class="form-row"><span>推理宽度</span><el-select v-model="maxInferenceWidth"><el-option v-for="item in widthOptions" :key="item" :label="`${item}px`" :value="item" /></el-select></div>
|
||||
<div class="form-row"><span>检测 FPS</span><el-input-number v-model="detectionFps" :min="1" :max="12" :step="1" controls-position="right" /></div>
|
||||
<div class="form-row"><span>分割 FPS</span><el-input-number v-model="segmentationFps" :min="1" :max="6" :step="1" controls-position="right" /></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="workspace-card" shadow="never">
|
||||
<template #header><div class="card-heading"><strong>运行时</strong><span>{{ runtimeReadyText }}</span></div></template>
|
||||
<div class="runtime-list">
|
||||
<div><span>GPU</span><strong>{{ capabilities?.gpu?.name || "未检测" }}</strong></div>
|
||||
<div><span>执行器</span><strong>{{ capabilities?.execution_provider || "-" }}</strong></div>
|
||||
<div><span>模型</span><strong>{{ readyModelCount }}/{{ capabilities?.models?.length || 0 }} 就绪</strong></div>
|
||||
<div><span>输出根目录</span><strong>{{ capabilities?.export?.output_root || "-" }}</strong></div>
|
||||
</div>
|
||||
</el-card>
|
||||
|
||||
<el-card class="workspace-card" shadow="never">
|
||||
<template #header><div class="card-heading"><strong>完整结果视频</strong><span>{{ exportStatusText }}</span></div></template>
|
||||
<div class="control-stack">
|
||||
<div class="form-row"><span>分析步长</span><el-input-number v-model="analysisStride" :min="1" :max="10" controls-position="right" /></div>
|
||||
<label class="switch-row compact"><span><strong>复用结果</strong><small>非推理帧沿用上一帧</small></span><el-switch v-model="reuseLastResult" /></label>
|
||||
<el-button type="primary" :icon="VideoPlay" :disabled="!canExport" :loading="exportStarting" @click="startExport">生成结果视频</el-button>
|
||||
<el-progress v-if="exportJob" :percentage="Math.round(exportJob.progress?.percent || 0)" :status="exportJob.status === 'failed' ? 'exception' : exportJob.status === 'succeeded' ? 'success' : undefined" />
|
||||
<div v-if="exportJob" class="export-meta">
|
||||
<span>{{ exportProgressText }}</span>
|
||||
<span v-if="exportJob.error">{{ exportJob.error }}</span>
|
||||
<span v-if="exportOutputDir"><el-icon><FolderOpened /></el-icon>{{ exportOutputDir }}</span>
|
||||
</div>
|
||||
<div v-if="exportJob?.status === 'succeeded'" class="export-actions">
|
||||
<el-button :icon="VideoPlay" @click="previewExportVideo">预览</el-button>
|
||||
<el-button :icon="Download" @click="downloadExportFile('annotated.mp4')">视频</el-button>
|
||||
<el-button :icon="Download" @click="downloadExportFile('results.json')">JSON</el-button>
|
||||
<el-button :icon="Download" @click="downloadExportFile('run-metadata.json')">元数据</el-button>
|
||||
</div>
|
||||
</div>
|
||||
</el-card>
|
||||
</aside>
|
||||
</section>
|
||||
|
||||
<el-dialog v-model="previewVisible" title="结果视频预览" width="860px" destroy-on-close>
|
||||
<video v-if="exportJob" class="export-preview-video" :src="videoExportFileUrl(exportJob.run_id, 'annotated.mp4')" controls autoplay></video>
|
||||
</el-dialog>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue";
|
||||
import { Cpu, Download, FolderOpened, Refresh, UploadFilled, VideoPlay } from "@element-plus/icons-vue";
|
||||
import { ElMessage } from "element-plus";
|
||||
import PageHeader from "../../components/common/PageHeader.vue";
|
||||
import {
|
||||
createVideoExportJob,
|
||||
inferVideoFrame,
|
||||
videoDemoCapabilities,
|
||||
videoExportFileUrl,
|
||||
videoExportJob,
|
||||
warmupVideoDemoModels,
|
||||
type FrameInferenceResponse,
|
||||
type VideoDemoWarning,
|
||||
type VideoExportJob,
|
||||
type VideoMaskRle
|
||||
} from "../../services/videoDemoApi";
|
||||
|
||||
const fileInput = ref<HTMLInputElement>();
|
||||
const stageRef = ref<HTMLElement>();
|
||||
const videoRef = ref<HTMLVideoElement>();
|
||||
const overlayRef = ref<HTMLCanvasElement>();
|
||||
const videoFile = ref<File>();
|
||||
const videoUrl = ref("");
|
||||
const capabilities = ref<any>();
|
||||
const capabilityLoading = ref(false);
|
||||
const warming = ref(false);
|
||||
const detectEnabled = ref(false);
|
||||
const segmentEnabled = ref(false);
|
||||
const confidenceThreshold = ref(0.45);
|
||||
const maskThreshold = ref(0.5);
|
||||
const maxInferenceWidth = ref(960);
|
||||
const detectionFps = ref(8);
|
||||
const segmentationFps = ref(3);
|
||||
const latestResult = ref<FrameInferenceResponse>();
|
||||
const realtimeWarnings = ref<VideoDemoWarning[]>([]);
|
||||
const pendingFrame = ref(false);
|
||||
const currentTime = ref(0);
|
||||
const exportStarting = ref(false);
|
||||
const exportJob = ref<VideoExportJob>();
|
||||
const exportOutputDir = ref("");
|
||||
const analysisStride = ref(1);
|
||||
const reuseLastResult = ref(true);
|
||||
const previewVisible = ref(false);
|
||||
let frameTimer: number | undefined;
|
||||
let exportTimer: number | undefined;
|
||||
let resizeObserver: ResizeObserver | undefined;
|
||||
const captureCanvas = document.createElement("canvas");
|
||||
const widthOptions = [640, 960, 1280];
|
||||
const selectedDetectionScene = ref("traffic-driving");
|
||||
const fallbackDetectionScenes = [
|
||||
{
|
||||
id: "inspection",
|
||||
label: "铁路/无人机巡检",
|
||||
detection_model_version: undefined,
|
||||
segmentation_model_version: undefined,
|
||||
default_confidence_threshold: 0.45
|
||||
},
|
||||
{
|
||||
id: "traffic-driving",
|
||||
label: "驾车/道路交通",
|
||||
detection_model_version: "traffic-yolov8n-coco",
|
||||
segmentation_model_version: "traffic-yolov8n-seg-coco",
|
||||
default_confidence_threshold: 0.35
|
||||
}
|
||||
];
|
||||
|
||||
const canExport = computed(() => Boolean(videoFile.value && (detectEnabled.value || segmentEnabled.value) && exportJob.value?.status !== "running" && exportJob.value?.status !== "queued"));
|
||||
const detectionScenes = computed(() => capabilities.value?.detection_scenes?.length ? capabilities.value.detection_scenes : fallbackDetectionScenes);
|
||||
const selectedScene = computed(() => detectionScenes.value.find((item: any) => item.id === selectedDetectionScene.value) || detectionScenes.value[0]);
|
||||
const selectedDetectionModelVersion = computed(() => selectedScene.value?.detection_model_version || undefined);
|
||||
const selectedSegmentationModelVersion = computed(() => selectedScene.value?.segmentation_model_version || undefined);
|
||||
const readyModelCount = computed(() => (capabilities.value?.models || []).filter((item: any) => item.artifact_installed).length);
|
||||
const providerText = computed(() => String(latestResult.value?.runtime.provider || capabilities.value?.execution_provider || "-"));
|
||||
const runtimeReadyText = computed(() => capabilities.value?.accelerated ? "GPU 就绪" : "CPU 或未就绪");
|
||||
const latencyText = computed(() => latestResult.value ? `${latestResult.value.runtime.total_latency_ms} ms` : "-");
|
||||
const currentTimeText = computed(() => `${currentTime.value.toFixed(2)} s`);
|
||||
const exportStatusText = computed(() => {
|
||||
if (!exportJob.value) return "未开始";
|
||||
return ({ queued: "排队中", running: "生成中", succeeded: "已完成", failed: "失败" } as Record<string, string>)[exportJob.value.status] || exportJob.value.status;
|
||||
});
|
||||
const exportProgressText = computed(() => {
|
||||
if (!exportJob.value) return "";
|
||||
const progress = exportJob.value.progress || { processed_frames: 0, total_frames: 0, percent: 0 };
|
||||
const total = progress.total_frames || "-";
|
||||
const eta = progress.eta_seconds ? `,剩余 ${Math.round(progress.eta_seconds)}s` : "";
|
||||
return `${progress.processed_frames}/${total} 帧${eta}`;
|
||||
});
|
||||
const resultRows = computed(() => {
|
||||
const detections = (latestResult.value?.results.detections || []).map((item) => ({
|
||||
type: "检测",
|
||||
category: item.category,
|
||||
confidence: item.confidence.toFixed(2),
|
||||
model: `${item.model_group} ${item.model_version || ""}`
|
||||
}));
|
||||
const segments = (latestResult.value?.results.segments || []).map((item) => ({
|
||||
type: "分割",
|
||||
category: item.category,
|
||||
confidence: item.confidence.toFixed(2),
|
||||
model: `${item.model_group} ${item.model_version || ""}`
|
||||
}));
|
||||
return [...detections, ...segments];
|
||||
});
|
||||
|
||||
async function loadCapabilities() {
|
||||
capabilityLoading.value = true;
|
||||
try {
|
||||
capabilities.value = await videoDemoCapabilities();
|
||||
maxInferenceWidth.value = capabilities.value?.recommended?.max_inference_width || maxInferenceWidth.value;
|
||||
detectionFps.value = capabilities.value?.recommended?.detection_fps || detectionFps.value;
|
||||
segmentationFps.value = capabilities.value?.recommended?.segmentation_fps || segmentationFps.value;
|
||||
const scenes = capabilities.value?.detection_scenes || [];
|
||||
if (scenes.length && !scenes.some((item: any) => item.id === selectedDetectionScene.value)) {
|
||||
selectedDetectionScene.value = scenes[0].id;
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.warning(errorMessage(error));
|
||||
} finally {
|
||||
capabilityLoading.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function warmupModels() {
|
||||
warming.value = true;
|
||||
try {
|
||||
const models = [detectEnabled.value && "vision-detector", segmentEnabled.value && "vision-segmenter"].filter(Boolean) as string[];
|
||||
const warmupTargets = models.length ? models : ["vision-detector", "vision-segmenter"];
|
||||
await warmupVideoDemoModels(warmupTargets, {
|
||||
"vision-detector": selectedDetectionModelVersion.value,
|
||||
"vision-segmenter": selectedSegmentationModelVersion.value
|
||||
});
|
||||
await loadCapabilities();
|
||||
ElMessage.success("模型预热完成");
|
||||
} catch (error) {
|
||||
ElMessage.warning(errorMessage(error));
|
||||
} finally {
|
||||
warming.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
function onVideoSelected(event: Event) {
|
||||
const file = (event.target as HTMLInputElement).files?.[0];
|
||||
if (!file) return;
|
||||
if (videoUrl.value) URL.revokeObjectURL(videoUrl.value);
|
||||
videoFile.value = file;
|
||||
videoUrl.value = URL.createObjectURL(file);
|
||||
latestResult.value = undefined;
|
||||
realtimeWarnings.value = [];
|
||||
exportJob.value = undefined;
|
||||
exportOutputDir.value = "";
|
||||
void nextTick(syncOverlaySize);
|
||||
}
|
||||
|
||||
function onVideoReady() {
|
||||
syncOverlaySize();
|
||||
drawOverlay();
|
||||
}
|
||||
|
||||
function onModeChanged() {
|
||||
clearOverlay();
|
||||
if (detectEnabled.value || segmentEnabled.value) startFrameLoop();
|
||||
else stopFrameLoop();
|
||||
}
|
||||
|
||||
function onSceneChanged() {
|
||||
const defaultThreshold = Number(selectedScene.value?.default_confidence_threshold);
|
||||
if (Number.isFinite(defaultThreshold)) confidenceThreshold.value = defaultThreshold;
|
||||
clearOverlay();
|
||||
if (detectEnabled.value || segmentEnabled.value) startFrameLoop();
|
||||
}
|
||||
|
||||
function startFrameLoop() {
|
||||
window.clearTimeout(frameTimer);
|
||||
const video = videoRef.value;
|
||||
if (!video || video.paused || (!detectEnabled.value && !segmentEnabled.value)) return;
|
||||
frameTimer = window.setTimeout(captureAndInferFrame, frameIntervalMs());
|
||||
}
|
||||
|
||||
function stopFrameLoop() {
|
||||
window.clearTimeout(frameTimer);
|
||||
}
|
||||
|
||||
async function captureAndInferFrame() {
|
||||
const video = videoRef.value;
|
||||
if (!video || video.paused || pendingFrame.value || (!detectEnabled.value && !segmentEnabled.value)) {
|
||||
startFrameLoop();
|
||||
return;
|
||||
}
|
||||
pendingFrame.value = true;
|
||||
try {
|
||||
const blob = await captureCurrentFrame(video);
|
||||
const form = new FormData();
|
||||
form.append("frame", blob, "frame.jpg");
|
||||
form.append("session_id", sessionId());
|
||||
form.append("timestamp_ms", String(video.currentTime * 1000));
|
||||
form.append("source_width", String(video.videoWidth));
|
||||
form.append("source_height", String(video.videoHeight));
|
||||
form.append("detect_enabled", String(detectEnabled.value));
|
||||
form.append("segment_enabled", String(segmentEnabled.value));
|
||||
form.append("confidence_threshold", String(confidenceThreshold.value));
|
||||
form.append("mask_threshold", String(maskThreshold.value));
|
||||
form.append("max_detections", "100");
|
||||
form.append("max_inference_width", String(maxInferenceWidth.value));
|
||||
form.append("detection_scene", selectedDetectionScene.value);
|
||||
if (selectedDetectionModelVersion.value) form.append("detection_model_version", selectedDetectionModelVersion.value);
|
||||
if (selectedSegmentationModelVersion.value) form.append("segmentation_model_version", selectedSegmentationModelVersion.value);
|
||||
const result = await inferVideoFrame(form);
|
||||
latestResult.value = result;
|
||||
realtimeWarnings.value = result.warnings || [];
|
||||
currentTime.value = video.currentTime;
|
||||
drawOverlay();
|
||||
} catch (error) {
|
||||
realtimeWarnings.value = [{ code: "FRAME_INFERENCE_FAILED", message: errorMessage(error) }];
|
||||
} finally {
|
||||
pendingFrame.value = false;
|
||||
startFrameLoop();
|
||||
}
|
||||
}
|
||||
|
||||
function captureCurrentFrame(video: HTMLVideoElement): Promise<Blob> {
|
||||
const scale = Math.min(1, maxInferenceWidth.value / Math.max(1, video.videoWidth));
|
||||
captureCanvas.width = Math.max(1, Math.round(video.videoWidth * scale));
|
||||
captureCanvas.height = Math.max(1, Math.round(video.videoHeight * scale));
|
||||
const context = captureCanvas.getContext("2d");
|
||||
if (!context) throw new Error("Canvas 不可用");
|
||||
context.drawImage(video, 0, 0, captureCanvas.width, captureCanvas.height);
|
||||
return new Promise((resolve, reject) => {
|
||||
captureCanvas.toBlob((blob) => blob ? resolve(blob) : reject(new Error("无法生成视频帧")), "image/jpeg", 0.82);
|
||||
});
|
||||
}
|
||||
|
||||
function frameIntervalMs() {
|
||||
const fps = segmentEnabled.value ? Math.min(detectionFps.value, segmentationFps.value) : detectionFps.value;
|
||||
return Math.max(80, Math.round(1000 / Math.max(1, fps)));
|
||||
}
|
||||
|
||||
async function startExport() {
|
||||
if (!videoFile.value) return;
|
||||
exportStarting.value = true;
|
||||
try {
|
||||
const form = new FormData();
|
||||
form.append("video", videoFile.value);
|
||||
form.append("detect_enabled", String(detectEnabled.value));
|
||||
form.append("segment_enabled", String(segmentEnabled.value));
|
||||
form.append("confidence_threshold", String(confidenceThreshold.value));
|
||||
form.append("mask_threshold", String(maskThreshold.value));
|
||||
form.append("max_inference_width", String(maxInferenceWidth.value));
|
||||
form.append("analysis_stride", String(analysisStride.value));
|
||||
form.append("reuse_last_result", String(reuseLastResult.value));
|
||||
form.append("max_detections", "100");
|
||||
form.append("detection_scene", selectedDetectionScene.value);
|
||||
if (selectedDetectionModelVersion.value) form.append("detection_model_version", selectedDetectionModelVersion.value);
|
||||
if (selectedSegmentationModelVersion.value) form.append("segmentation_model_version", selectedSegmentationModelVersion.value);
|
||||
const created = await createVideoExportJob(form);
|
||||
exportOutputDir.value = created.output_dir;
|
||||
exportJob.value = { ...created, progress: { processed_frames: 0, percent: 0 }, outputs: {}, warnings: [] } as VideoExportJob;
|
||||
pollExportJob(created.run_id);
|
||||
} catch (error) {
|
||||
ElMessage.error(errorMessage(error));
|
||||
} finally {
|
||||
exportStarting.value = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function pollExportJob(runId: string) {
|
||||
window.clearTimeout(exportTimer);
|
||||
try {
|
||||
exportJob.value = await videoExportJob(runId);
|
||||
if (exportJob.value.outputs?.annotated_video) exportOutputDir.value = exportJob.value.outputs.annotated_video.replace(/[/\\]annotated\.mp4$/, "");
|
||||
if (["queued", "running"].includes(exportJob.value.status)) {
|
||||
exportTimer = window.setTimeout(() => pollExportJob(runId), 1200);
|
||||
}
|
||||
} catch (error) {
|
||||
ElMessage.warning(errorMessage(error));
|
||||
}
|
||||
}
|
||||
|
||||
function previewExportVideo() {
|
||||
previewVisible.value = true;
|
||||
}
|
||||
|
||||
function downloadExportFile(fileName: string) {
|
||||
if (!exportJob.value) return;
|
||||
window.open(videoExportFileUrl(exportJob.value.run_id, fileName), "_blank", "noopener,noreferrer");
|
||||
}
|
||||
|
||||
function syncOverlaySize() {
|
||||
const canvas = overlayRef.value;
|
||||
const stage = stageRef.value;
|
||||
if (!canvas || !stage) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
canvas.width = Math.max(1, Math.round(rect.width * dpr));
|
||||
canvas.height = Math.max(1, Math.round(rect.height * dpr));
|
||||
canvas.style.width = `${rect.width}px`;
|
||||
canvas.style.height = `${rect.height}px`;
|
||||
drawOverlay();
|
||||
}
|
||||
|
||||
function drawOverlay() {
|
||||
const canvas = overlayRef.value;
|
||||
const stage = stageRef.value;
|
||||
const video = videoRef.value;
|
||||
if (!canvas || !stage || !video) return;
|
||||
const rect = stage.getBoundingClientRect();
|
||||
const dpr = window.devicePixelRatio || 1;
|
||||
const context = canvas.getContext("2d");
|
||||
if (!context) return;
|
||||
context.setTransform(dpr, 0, 0, dpr, 0, 0);
|
||||
context.clearRect(0, 0, rect.width, rect.height);
|
||||
if (!latestResult.value || !video.videoWidth || !video.videoHeight) return;
|
||||
const box = videoDrawBox(rect.width, rect.height, video.videoWidth, video.videoHeight);
|
||||
for (const segment of latestResult.value.results.segments) {
|
||||
if (segment.mask) drawSegmentMask(context, box, segment.mask, segment.category);
|
||||
else drawSegment(context, box, segment.polygon, segment.category);
|
||||
}
|
||||
for (const detection of latestResult.value.results.detections) drawDetection(context, box, detection.bbox, detection.category, detection.confidence, colorFor(detection.category));
|
||||
}
|
||||
|
||||
function clearOverlay() {
|
||||
latestResult.value = undefined;
|
||||
realtimeWarnings.value = [];
|
||||
const canvas = overlayRef.value;
|
||||
const context = canvas?.getContext("2d");
|
||||
if (canvas && context) context.clearRect(0, 0, canvas.width, canvas.height);
|
||||
}
|
||||
|
||||
function videoDrawBox(stageWidth: number, stageHeight: number, sourceWidth: number, sourceHeight: number) {
|
||||
const stageRatio = stageWidth / stageHeight;
|
||||
const videoRatio = sourceWidth / sourceHeight;
|
||||
if (videoRatio > stageRatio) {
|
||||
const width = stageWidth;
|
||||
const height = width / videoRatio;
|
||||
return { x: 0, y: (stageHeight - height) / 2, width, height };
|
||||
}
|
||||
const height = stageHeight;
|
||||
const width = height * videoRatio;
|
||||
return { x: (stageWidth - width) / 2, y: 0, width, height };
|
||||
}
|
||||
|
||||
function drawSegment(context: CanvasRenderingContext2D, box: any, polygon: number[][], category: string) {
|
||||
if (!polygon?.length) return;
|
||||
const color = colorFor(category);
|
||||
context.beginPath();
|
||||
polygon.forEach((point, index) => {
|
||||
const x = box.x + point[0] * box.width;
|
||||
const y = box.y + point[1] * box.height;
|
||||
index === 0 ? context.moveTo(x, y) : context.lineTo(x, y);
|
||||
});
|
||||
context.closePath();
|
||||
context.fillStyle = alphaColorFor(category, 0.33);
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 2;
|
||||
context.fill();
|
||||
context.stroke();
|
||||
}
|
||||
|
||||
function drawSegmentMask(context: CanvasRenderingContext2D, box: any, mask: VideoMaskRle, category: string) {
|
||||
if (mask.encoding !== "rle" || mask.width <= 0 || mask.height <= 0) return;
|
||||
const decoded = decodeRleMask(mask);
|
||||
const maskCanvas = document.createElement("canvas");
|
||||
maskCanvas.width = mask.width;
|
||||
maskCanvas.height = mask.height;
|
||||
const maskContext = maskCanvas.getContext("2d");
|
||||
if (!maskContext) return;
|
||||
const imageData = maskContext.createImageData(mask.width, mask.height);
|
||||
const { r, g, b } = rgbFor(category);
|
||||
for (let index = 0; index < decoded.length; index += 1) {
|
||||
if (!decoded[index]) continue;
|
||||
const offset = index * 4;
|
||||
imageData.data[offset] = r;
|
||||
imageData.data[offset + 1] = g;
|
||||
imageData.data[offset + 2] = b;
|
||||
imageData.data[offset + 3] = 92;
|
||||
}
|
||||
maskContext.putImageData(imageData, 0, 0);
|
||||
context.save();
|
||||
context.imageSmoothingEnabled = false;
|
||||
context.drawImage(maskCanvas, box.x, box.y, box.width, box.height);
|
||||
context.restore();
|
||||
}
|
||||
|
||||
function decodeRleMask(mask: VideoMaskRle) {
|
||||
const total = Math.max(0, Math.floor(mask.width * mask.height));
|
||||
const decoded = new Uint8Array(total);
|
||||
let offset = 0;
|
||||
let value = 0;
|
||||
for (const count of mask.counts) {
|
||||
const length = Math.max(0, Math.floor(Number(count) || 0));
|
||||
const end = Math.min(total, offset + length);
|
||||
if (value) decoded.fill(1, offset, end);
|
||||
offset = end;
|
||||
if (offset >= total) break;
|
||||
value = value ? 0 : 1;
|
||||
}
|
||||
return decoded;
|
||||
}
|
||||
|
||||
function drawDetection(context: CanvasRenderingContext2D, box: any, bbox: number[], label: string, confidence: number, color: string) {
|
||||
if (bbox.length !== 4) return;
|
||||
const x = box.x + bbox[0] * box.width;
|
||||
const y = box.y + bbox[1] * box.height;
|
||||
const width = (bbox[2] - bbox[0]) * box.width;
|
||||
const height = (bbox[3] - bbox[1]) * box.height;
|
||||
context.strokeStyle = color;
|
||||
context.lineWidth = 2;
|
||||
context.strokeRect(x, y, width, height);
|
||||
const text = `${label} ${confidence.toFixed(2)}`;
|
||||
context.font = "12px Microsoft YaHei, sans-serif";
|
||||
const textWidth = context.measureText(text).width + 10;
|
||||
context.fillStyle = color;
|
||||
context.fillRect(x, Math.max(0, y - 22), textWidth, 20);
|
||||
context.fillStyle = "#fff";
|
||||
context.fillText(text, x + 5, Math.max(14, y - 8));
|
||||
}
|
||||
|
||||
function hueFor(value: string) {
|
||||
let hash = 0;
|
||||
for (let index = 0; index < value.length; index += 1) hash = ((hash << 5) - hash) + value.charCodeAt(index);
|
||||
return Math.abs(hash) % 360;
|
||||
}
|
||||
|
||||
function colorFor(value: string) {
|
||||
return `hsl(${hueFor(value)}, 72%, 48%)`;
|
||||
}
|
||||
|
||||
function alphaColorFor(value: string, alpha: number) {
|
||||
return `hsla(${hueFor(value)}, 72%, 48%, ${alpha})`;
|
||||
}
|
||||
|
||||
function rgbFor(value: string) {
|
||||
const hue = hueFor(value) / 360;
|
||||
const saturation: number = 0.72;
|
||||
const lightness: number = 0.48;
|
||||
if (saturation === 0) {
|
||||
const gray = Math.round(lightness * 255);
|
||||
return { r: gray, g: gray, b: gray };
|
||||
}
|
||||
const q = lightness < 0.5 ? lightness * (1 + saturation) : lightness + saturation - lightness * saturation;
|
||||
const p = 2 * lightness - q;
|
||||
const toRgb = (channel: number) => {
|
||||
let t = channel;
|
||||
if (t < 0) t += 1;
|
||||
if (t > 1) t -= 1;
|
||||
if (t < 1 / 6) return p + (q - p) * 6 * t;
|
||||
if (t < 1 / 2) return q;
|
||||
if (t < 2 / 3) return p + (q - p) * (2 / 3 - t) * 6;
|
||||
return p;
|
||||
};
|
||||
return {
|
||||
r: Math.round(toRgb(hue + 1 / 3) * 255),
|
||||
g: Math.round(toRgb(hue) * 255),
|
||||
b: Math.round(toRgb(hue - 1 / 3) * 255)
|
||||
};
|
||||
}
|
||||
|
||||
function sessionId() {
|
||||
return `video-demo-${videoFile.value?.name || "local"}`;
|
||||
}
|
||||
|
||||
function errorMessage(error: unknown) {
|
||||
const item = error as { response?: { data?: { detail?: string; message?: string } }; message?: string };
|
||||
return item.response?.data?.detail || item.response?.data?.message || item.message || "操作失败";
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
void loadCapabilities();
|
||||
resizeObserver = new ResizeObserver(syncOverlaySize);
|
||||
if (stageRef.value) resizeObserver.observe(stageRef.value);
|
||||
window.addEventListener("resize", syncOverlaySize);
|
||||
});
|
||||
|
||||
watch([confidenceThreshold, maskThreshold, maxInferenceWidth], clearOverlay);
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
stopFrameLoop();
|
||||
window.clearTimeout(exportTimer);
|
||||
window.removeEventListener("resize", syncOverlaySize);
|
||||
resizeObserver?.disconnect();
|
||||
if (videoUrl.value) URL.revokeObjectURL(videoUrl.value);
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.video-demo-layout { display: grid; grid-template-columns: minmax(0, 1fr) 360px; gap: 16px; align-items: start; }
|
||||
.video-workspace, .video-control-panel { min-width: 0; display: grid; gap: 14px; }
|
||||
.video-stage { position: relative; min-height: 520px; overflow: hidden; border: 1px solid #d5dee9; border-radius: 6px; background: #111827; }
|
||||
.video-stage video, .video-overlay { position: absolute; inset: 0; width: 100%; height: 100%; }
|
||||
.video-stage video { object-fit: contain; background: #111827; }
|
||||
.video-overlay { z-index: 2; pointer-events: none; }
|
||||
.video-empty { position: absolute; inset: 0; z-index: 3; display: grid; place-items: center; align-content: center; gap: 10px; color: #dbeafe; background: linear-gradient(135deg, #111827, #1f2937); }
|
||||
.video-empty .el-icon { font-size: 46px; }
|
||||
.video-result-strip { display: grid; grid-template-columns: repeat(5, minmax(0, 1fr)); gap: 10px; }
|
||||
.video-result-strip > div, .runtime-list > div { min-width: 0; padding: 12px; border: 1px solid #dce4ed; border-radius: 6px; background: #fff; }
|
||||
.video-result-strip span, .video-result-strip strong, .runtime-list span, .runtime-list strong { display: block; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||||
.video-result-strip span, .runtime-list span { color: #64748b; font-size: 11px; }
|
||||
.video-result-strip strong, .runtime-list strong { margin-top: 6px; color: #17365d; font-size: 15px; }
|
||||
.result-card :deep(.el-alert) { margin-bottom: 8px; }
|
||||
.control-stack { display: grid; gap: 14px; }
|
||||
.switch-row { min-height: 58px; padding: 10px 11px; display: flex; align-items: center; justify-content: space-between; gap: 12px; border: 1px solid #e2e8f0; border-radius: 6px; background: #f8fafc; }
|
||||
.switch-row.compact { min-height: 50px; }
|
||||
.switch-row span strong, .switch-row span small { display: block; }
|
||||
.switch-row span strong { color: #17365d; font-size: 13px; }
|
||||
.switch-row span small { margin-top: 4px; color: #64748b; font-size: 11px; }
|
||||
.slider-field > span { display: block; margin-bottom: 4px; color: #475569; font-size: 12px; }
|
||||
.form-row { display: grid; grid-template-columns: 92px minmax(0, 1fr); align-items: center; gap: 10px; color: #475569; font-size: 12px; }
|
||||
.runtime-list { display: grid; gap: 8px; }
|
||||
.export-meta { display: grid; gap: 6px; color: #64748b; font-size: 12px; line-height: 1.5; }
|
||||
.export-meta span { min-width: 0; display: flex; align-items: center; gap: 5px; word-break: break-all; }
|
||||
.export-actions { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 8px; }
|
||||
.export-actions :deep(.el-button) { margin-left: 0; }
|
||||
.export-preview-video { width: 100%; max-height: 70vh; background: #111827; }
|
||||
@media (max-width: 1180px) {
|
||||
.video-demo-layout { grid-template-columns: 1fr; }
|
||||
.video-control-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); }
|
||||
}
|
||||
@media (max-width: 760px) {
|
||||
.video-stage { min-height: 360px; }
|
||||
.video-control-panel, .video-result-strip { grid-template-columns: 1fr; }
|
||||
.form-row { grid-template-columns: 1fr; }
|
||||
}
|
||||
</style>
|
||||
@@ -15,7 +15,7 @@
|
||||
<section v-else class="gis-page-layout workorder-map-layout"><RailwayGisMap :alarms="filteredRows" :selected-id="String(selected?.alarm_id || '')" :layers="{ routes: false, rules: true, alarms: true }" @select="openDetail" /><aside class="gis-detail-panel"><template v-if="selected"><h2>{{ selected.scene }}</h2><p class="entity-id">{{ selected.workorder_id }}</p><el-descriptions :column="1" border size="small"><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item><el-descriptions-item label="责任人">{{ selected.assignee || '待分配' }}</el-descriptions-item><el-descriptions-item label="位置">{{ locationText(selected.location) }}</el-descriptions-item></el-descriptions><div class="drawer-actions"><el-button type="primary" @click="openDetail(selected)">查看工单</el-button></div></template><el-empty v-else description="选择地图点位" /></aside></section>
|
||||
|
||||
<el-drawer v-model="detailVisible" title="工单详情" size="660px"><template v-if="selected"><el-steps :active="workorderStep(selected.status)" finish-status="success" align-center><el-step title="工单创建" /><el-step title="接单到场" /><el-step title="现场处置" /><el-step title="复核关闭" /></el-steps><el-descriptions class="workorder-descriptions" :column="1" border><el-descriptions-item label="工单编号"><span class="entity-id">{{ selected.workorder_id }}</span></el-descriptions-item><el-descriptions-item label="关联告警"><el-button link type="primary" @click="router.push(`/alarms/${selected.alarm_id}`)">{{ selected.alarm_id }}</el-button></el-descriptions-item><el-descriptions-item label="隐患场景">{{ selected.scene }} / {{ selected.category }}</el-descriptions-item><el-descriptions-item label="责任人">{{ selected.assignee || '待分配' }}</el-descriptions-item><el-descriptions-item label="处置结果">{{ selected.close_result || '尚未提交' }}</el-descriptions-item><el-descriptions-item label="处置意见">{{ selected.comment || '-' }}</el-descriptions-item><el-descriptions-item label="状态">{{ statusLabel(selected.status) }}</el-descriptions-item></el-descriptions>
|
||||
<div class="drawer-actions workorder-action-bar"><el-button v-if="selected.status === 'created'" type="primary" @click="act(selected, 'ACCEPT')">接收工单</el-button><el-button v-if="selected.status === 'accepted'" type="primary" @click="act(selected, 'DEPART')">出发</el-button><el-button v-if="selected.status === 'en_route'" type="primary" @click="act(selected, 'ARRIVE')">到场签到</el-button><el-button v-if="selected.status === 'on_site'" type="primary" @click="act(selected, 'START_PROCESSING')">开始处置</el-button><el-button v-if="selected.status === 'processing'" type="success" @click="act(selected, 'SUBMIT')">提交处置</el-button><el-button v-if="selected.status === 'returned'" type="primary" @click="act(selected, 'REOPEN')">重新处置</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" type="success" @click="act(selected, 'APPROVE')">复核通过</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" type="danger" plain @click="act(selected, 'RETURN')">退回重办</el-button><el-button v-if="selected.status !== 'closed'" @click="redispatch(selected)">人工改派</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" @click="requestReinspection(selected)">创建复飞任务</el-button><el-button @click="router.push({ path: '/gis', query: { workorderId: selected.workorder_id } })">地图定位</el-button></div>
|
||||
<div class="drawer-actions workorder-action-bar"><el-button v-if="selected.status === 'created'" type="primary" @click="act(selected, 'ACCEPT')">接收工单</el-button><el-button v-if="selected.status === 'accepted'" type="primary" @click="act(selected, 'DEPART')">出发</el-button><el-button v-if="selected.status === 'en_route'" type="primary" @click="act(selected, 'ARRIVE')">到场签到</el-button><el-button v-if="selected.status === 'on_site'" type="primary" @click="act(selected, 'START_PROCESSING')">开始处置</el-button><el-button v-if="selected.status === 'processing'" type="success" @click="act(selected, 'SUBMIT')">提交处置</el-button><el-button v-if="selected.status === 'returned'" type="primary" @click="act(selected, 'REOPEN')">重新处置</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" type="success" @click="act(selected, 'APPROVE')">复核通过</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" type="danger" plain @click="act(selected, 'RETURN')">退回重办</el-button><el-button v-if="selected.status !== 'closed'" @click="redispatch(selected)">人工改派</el-button><el-button v-if="['submitted','reviewing'].includes(String(selected.status))" @click="requestReinspection(selected)">创建复飞任务</el-button><el-button @click="openOnMap(selected)">地图定位</el-button></div>
|
||||
<div class="subsection-head evidence-head"><div><strong>现场证据</strong><span>整改前、中、后原始文件与摘要</span></div><div><el-select v-model="evidencePhase" size="small"><el-option label="整改前" value="BEFORE" /><el-option label="整改中" value="DURING" /><el-option label="整改后" value="AFTER" /></el-select><el-button type="primary" plain size="small" :icon="Upload" @click="evidenceInput?.click()">上传证据</el-button><input ref="evidenceInput" class="hidden-file-input" type="file" accept="image/*,video/*,audio/*,.pdf" @change="uploadEvidence" /></div></div>
|
||||
<div v-if="evidence.length" class="evidence-file-grid"><a v-for="item in evidence" :key="item.attachment_id" :href="workorderEvidenceUrl(String(item.attachment_id))" target="_blank"><el-icon><Picture /></el-icon><span><strong>{{ item.file_name }}</strong><small>{{ evidencePhaseLabel(item.phase) }} · {{ fileSize(item.size_bytes) }}</small></span></a></div><el-empty v-else description="尚未上传现场证据" :image-size="54" />
|
||||
<h3 class="drawer-section-title">处置时间轴</h3><el-timeline><el-timeline-item v-for="item in actions" :key="item.action_id" :timestamp="formatDateTime(item.created_at)" placement="top"><strong>{{ actionLabel(item.action_type) }}</strong><p>{{ item.operator_name }} · {{ item.comment || item.result || '状态已更新' }}</p></el-timeline-item></el-timeline>
|
||||
@@ -44,6 +44,7 @@ function matchTab(row: Row) { if (activeTab.value === "all") return true; if (ac
|
||||
function locationText(value: unknown) { const location = safeObject(value); return [location.mileage, location.distance_to_track_m ? `${location.distance_to_track_m}m` : ""].filter(Boolean).join(" / ") || "线路邻近"; }
|
||||
function formatDateTime(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
|
||||
function workorderStep(status: unknown) { return ({ created: 1, accepted: 2, en_route: 2, on_site: 2, processing: 3, returned: 3, submitted: 3, reviewing: 3, reinspection_required: 3, closed: 4 } as Record<string, number>)[String(status)] || 1; }
|
||||
function openOnMap(row: Row) { router.push({ path: "/gis", query: { workorderId: String(row.workorder_id), alarmId: String(row.alarm_id), taskId: String(row.task_id || "") } }); }
|
||||
async function load() { loading.value = true; try { rows.value = await workorders(); const id = String(route.params.workorderId || ""); if (id) { const row = rows.value.find((item) => String(item.workorder_id) === id); if (row) openDetail(row); } } finally { loading.value = false; } }
|
||||
async function openDetail(row: Row) { selected.value = row; detailVisible.value = true; router.replace({ path: `/workorders/${row.workorder_id}`, query: route.query }); await loadDetail(row); }
|
||||
async function loadDetail(row: Row) { [actions.value, evidence.value] = await Promise.all([workorderActions(String(row.workorder_id)), workorderEvidence(String(row.workorder_id))]); }
|
||||
|
||||
@@ -12,6 +12,11 @@ export default defineConfig({
|
||||
"/actuator": {
|
||||
target: "http://localhost:8080",
|
||||
changeOrigin: true
|
||||
},
|
||||
"/vision-api": {
|
||||
target: "http://localhost:8101",
|
||||
changeOrigin: true,
|
||||
rewrite: (path) => path.replace(/^\/vision-api/, "")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -90,6 +90,13 @@ services:
|
||||
build:
|
||||
context: ../ai-services/vision-inference
|
||||
container_name: rail-vision-inference
|
||||
environment:
|
||||
RAIL_RUNTIME_PROFILE: cpu-local
|
||||
RAIL_MODEL_REGISTRY: /app/config/model-registry.json
|
||||
RAIL_MODEL_DIR: /models
|
||||
volumes:
|
||||
- ../runtime/models:/models:ro
|
||||
- ./model-registry/cpu-models.json:/app/config/model-registry.json:ro
|
||||
ports:
|
||||
- "8101:8101"
|
||||
healthcheck:
|
||||
@@ -102,6 +109,13 @@ services:
|
||||
build:
|
||||
context: ../ai-services/pointcloud-analysis
|
||||
container_name: rail-pointcloud-analysis
|
||||
environment:
|
||||
RAIL_RUNTIME_PROFILE: cpu-local
|
||||
RAIL_MODEL_REGISTRY: /app/config/model-registry.json
|
||||
RAIL_MODEL_DIR: /models
|
||||
volumes:
|
||||
- ../runtime/models:/models:ro
|
||||
- ./model-registry/cpu-models.json:/app/config/model-registry.json:ro
|
||||
ports:
|
||||
- "8102:8102"
|
||||
healthcheck:
|
||||
@@ -145,7 +159,8 @@ services:
|
||||
kafka:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/rail_inspection
|
||||
SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/rail_inspection?currentSchema=uav_access
|
||||
SPRING_DATASOURCE_SCHEMA: uav_access
|
||||
SPRING_DATASOURCE_USERNAME: rail
|
||||
SPRING_DATASOURCE_PASSWORD: rail
|
||||
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
|
||||
|
||||
@@ -19,6 +19,24 @@
|
||||
"scale": 0.00392156862745098,
|
||||
"color_order": "RGB"
|
||||
},
|
||||
{
|
||||
"model_group": "vision-detector",
|
||||
"model_version": "traffic-yolov8n-coco",
|
||||
"active": false,
|
||||
"display_name": "YOLOv8n COCO 交通演示本地 ONNX",
|
||||
"family": "YOLOv8",
|
||||
"runtime": "onnxruntime-cpu",
|
||||
"artifact": "vision-detector/yolov8n-coco/model.onnx",
|
||||
"labels": "vision-detector/yolov8n-coco/labels.txt",
|
||||
"parser": "ultralytics-yolo",
|
||||
"input_size": 640,
|
||||
"batch_size": 1,
|
||||
"precision": "FP32",
|
||||
"mean": [0.0, 0.0, 0.0],
|
||||
"std": [1.0, 1.0, 1.0],
|
||||
"scale": 0.00392156862745098,
|
||||
"color_order": "RGB"
|
||||
},
|
||||
{
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "cpu-v1.0.0",
|
||||
@@ -37,6 +55,24 @@
|
||||
"scale": 0.00392156862745098,
|
||||
"color_order": "RGB"
|
||||
},
|
||||
{
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "traffic-yolov8n-seg-coco",
|
||||
"active": false,
|
||||
"display_name": "YOLOv8n-seg COCO 交通实例分割本地 ONNX",
|
||||
"family": "YOLOv8-seg",
|
||||
"runtime": "onnxruntime-cpu",
|
||||
"artifact": "vision-segmenter/yolov8n-seg-coco/model.onnx",
|
||||
"labels": "vision-segmenter/yolov8n-seg-coco/labels.txt",
|
||||
"parser": "ultralytics-yolo-seg",
|
||||
"input_size": 640,
|
||||
"batch_size": 1,
|
||||
"precision": "FP32",
|
||||
"mean": [0.0, 0.0, 0.0],
|
||||
"std": [1.0, 1.0, 1.0],
|
||||
"scale": 0.00392156862745098,
|
||||
"color_order": "RGB"
|
||||
},
|
||||
{
|
||||
"model_group": "change-detector",
|
||||
"model_version": "cpu-v1.0.0",
|
||||
|
||||
@@ -10,4 +10,30 @@ runs/
|
||||
artifacts/
|
||||
cache/
|
||||
.env
|
||||
dataset/prepared/
|
||||
|
||||
# Local datasets and downloaded experiment bundles
|
||||
/dataset/
|
||||
/Enhanced-YOLO26s-for-High-Speed-Railway-Foreign-Object-Detection-via-ECA-BiFPN-and-P2-Head-main/
|
||||
/*.zip
|
||||
|
||||
# Local geospatial inputs
|
||||
/dataview/*.tif
|
||||
/dataview/*.tiff
|
||||
|
||||
# Model checkpoints and exported weights
|
||||
*.pt
|
||||
*.pth
|
||||
*.ckpt
|
||||
*.safetensors
|
||||
*.onnx
|
||||
*.engine
|
||||
*.pdparams
|
||||
*.pdopt
|
||||
*.pdema
|
||||
|
||||
# Training/framework caches
|
||||
*.cache
|
||||
wandb/
|
||||
mlruns/
|
||||
lightning_logs/
|
||||
.ultralytics/
|
||||
|
||||
@@ -0,0 +1,11 @@
|
||||
<PAMDataset>
|
||||
<PAMRasterBand band="1">
|
||||
<Metadata>
|
||||
<MDI key="STATISTICS_MINIMUM">1819.5457763672</MDI>
|
||||
<MDI key="STATISTICS_MAXIMUM">1976.1481933594</MDI>
|
||||
<MDI key="STATISTICS_MEAN">1884.4356526837</MDI>
|
||||
<MDI key="STATISTICS_STDDEV">43.989041479334</MDI>
|
||||
<MDI key="STATISTICS_VALID_PERCENT">68.49</MDI>
|
||||
</Metadata>
|
||||
</PAMRasterBand>
|
||||
</PAMDataset>
|
||||
@@ -0,0 +1,13 @@
|
||||
<PAMDataset>
|
||||
<PAMRasterBand band="1">
|
||||
<Description>mean</Description>
|
||||
<Metadata>
|
||||
<MDI key="STATISTICS_APPROXIMATE">YES</MDI>
|
||||
<MDI key="STATISTICS_MINIMUM">1221.9730373912</MDI>
|
||||
<MDI key="STATISTICS_MAXIMUM">1474.4719624217</MDI>
|
||||
<MDI key="STATISTICS_MEAN">1341.5028279639</MDI>
|
||||
<MDI key="STATISTICS_STDDEV">63.088572769604</MDI>
|
||||
<MDI key="STATISTICS_VALID_PERCENT">54.81</MDI>
|
||||
</Metadata>
|
||||
</PAMRasterBand>
|
||||
</PAMDataset>
|
||||
Binary file not shown.
+8
@@ -75,6 +75,14 @@ public class CapabilityCompletionController {
|
||||
return ApiResponse.ok(service.createRoute(request));
|
||||
}
|
||||
|
||||
@PutMapping("/inspection/tasks/{taskId}/route")
|
||||
public ApiResponse<?> bindTaskRoute(
|
||||
@PathVariable String taskId,
|
||||
@Valid @RequestBody CapabilityRequests.BindTaskRouteRequest request
|
||||
) {
|
||||
return ApiResponse.ok(service.bindTaskRoute(taskId, request));
|
||||
}
|
||||
|
||||
@PostMapping("/route-versions/{versionId}/validate")
|
||||
public ApiResponse<?> validateRoute(@PathVariable String versionId) {
|
||||
return ApiResponse.ok(service.validateRouteVersion(versionId));
|
||||
|
||||
+8
@@ -52,6 +52,14 @@ public final class CapabilityRequests {
|
||||
) {
|
||||
}
|
||||
|
||||
public record BindTaskRouteRequest(
|
||||
@NotBlank String routeId,
|
||||
String expectedObjectId,
|
||||
Long expectedTaskVersion,
|
||||
String boundBy
|
||||
) {
|
||||
}
|
||||
|
||||
public record CreateConnectionRequest(
|
||||
@NotBlank String name,
|
||||
@NotBlank String vendorCode,
|
||||
|
||||
+94
-6
@@ -404,6 +404,90 @@ public class CapabilityCompletionService {
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> bindTaskRoute(
|
||||
String taskId,
|
||||
CapabilityRequests.BindTaskRouteRequest request
|
||||
) {
|
||||
Map<String, Object> task = requireRow("""
|
||||
select id,status,route_id,object_scope::text as object_scope,version_no
|
||||
from inspection_tasks where id=? for update
|
||||
""", taskId);
|
||||
long taskVersion = ((Number) task.get("version_no")).longValue();
|
||||
if (request.expectedTaskVersion() != null && request.expectedTaskVersion() != taskVersion) {
|
||||
throw conflict("任务状态已变化,请刷新后重新配置航线");
|
||||
}
|
||||
boolean hasMission = Boolean.TRUE.equals(jdbc.queryForObject(
|
||||
"select exists(select 1 from flight_missions where task_id=?)",
|
||||
Boolean.class,
|
||||
taskId
|
||||
));
|
||||
String blockReason = InspectionTaskWorkflow.routeBindingBlockReason(
|
||||
String.valueOf(task.get("status")),
|
||||
hasMission
|
||||
);
|
||||
if (blockReason != null) {
|
||||
throw conflict(blockReason);
|
||||
}
|
||||
|
||||
Map<String, Object> route = requireRow("""
|
||||
select r.id as route_id,r.name,r.object_id,r.status,
|
||||
rv.id as route_version_id,rv.status as version_status
|
||||
from routes r
|
||||
left join route_versions rv on rv.id=r.current_version_id
|
||||
where r.id=?
|
||||
""", request.routeId());
|
||||
if (!"PUBLISHED".equals(String.valueOf(route.get("status")))
|
||||
|| !"PUBLISHED".equals(String.valueOf(route.get("version_status")))) {
|
||||
throw conflict("只能为任务绑定已发布航线");
|
||||
}
|
||||
String routeObjectId = route.get("object_id") == null
|
||||
? null : blankToNull(String.valueOf(route.get("object_id")));
|
||||
if (request.expectedObjectId() != null
|
||||
&& !request.expectedObjectId().isBlank()
|
||||
&& !request.expectedObjectId().equals(routeObjectId)) {
|
||||
throw conflict("所选航线与任务指定巡检对象不一致");
|
||||
}
|
||||
Integer scopedObjectCount = jdbc.queryForObject(
|
||||
"select count(*) from inspection_task_objects where task_id=?",
|
||||
Integer.class,
|
||||
taskId
|
||||
);
|
||||
if (scopedObjectCount != null && scopedObjectCount > 0) {
|
||||
boolean compatible = routeObjectId != null && Boolean.TRUE.equals(jdbc.queryForObject(
|
||||
"select exists(select 1 from inspection_task_objects where task_id=? and object_id=?)",
|
||||
Boolean.class,
|
||||
taskId,
|
||||
routeObjectId
|
||||
));
|
||||
if (!compatible) {
|
||||
throw conflict("所选航线不属于任务巡检对象范围");
|
||||
}
|
||||
}
|
||||
|
||||
Instant now = Instant.now();
|
||||
jdbc.update("""
|
||||
update inspection_tasks
|
||||
set route_id=?,updated_at=?,version_no=version_no+1
|
||||
where id=?
|
||||
""", request.routeId(), Timestamp.from(now), taskId);
|
||||
publish("inspection.task.route.bound", Map.of(
|
||||
"task_id", taskId,
|
||||
"route_id", request.routeId(),
|
||||
"route_version_id", String.valueOf(route.get("route_version_id")),
|
||||
"bound_by", defaultString(request.boundBy(), "user-dispatcher")
|
||||
));
|
||||
Map<String, Object> result = new LinkedHashMap<>();
|
||||
result.put("task_id", taskId);
|
||||
result.put("route_id", request.routeId());
|
||||
result.put("route_name", route.get("name"));
|
||||
result.put("route_version_id", route.get("route_version_id"));
|
||||
result.put("object_id", routeObjectId);
|
||||
result.put("task_version", taskVersion + 1);
|
||||
result.put("status", "BOUND");
|
||||
return result;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> validateRouteVersion(String versionId) {
|
||||
Map<String, Object> version = requireRow(
|
||||
@@ -846,8 +930,11 @@ public class CapabilityCompletionService {
|
||||
String targetStatus = MissionStateMachine.targetForCommand(currentStatus, command);
|
||||
int progress = ((Number) mission.get("progress")).intValue();
|
||||
if ("SIMULATE_PROGRESS".equals(command)) {
|
||||
if (!"FLYING".equals(currentStatus)) throw conflict("只有飞行中的模拟任务可以推进进度");
|
||||
progress = Math.min(100, progress + 25);
|
||||
try {
|
||||
progress = MissionStateMachine.advanceSimulatedProgress(currentStatus, progress);
|
||||
} catch (IllegalArgumentException exception) {
|
||||
throw conflict(exception.getMessage());
|
||||
}
|
||||
if (progress >= 100) targetStatus = "COMPLETED";
|
||||
} else if (!MissionStateMachine.canTransition(currentStatus, targetStatus)) {
|
||||
throw conflict("飞行任务不能从 " + currentStatus + " 执行 " + command + " 到 " + targetStatus);
|
||||
@@ -1100,7 +1187,7 @@ public class CapabilityCompletionService {
|
||||
List<Map<String, Object>> features = new ArrayList<>();
|
||||
for (Map<String, Object> row : jdbc.queryForList("select id,name,object_type,line_id,risk_level,status,ST_AsGeoJSON(geom) as geometry from inspection_objects where status='ACTIVE'")) {
|
||||
features.add(feature(row.get("id"), "inspection_object", row.get("geometry"), Map.of(
|
||||
"name", row.get("name"), "object_type", row.get("object_type"), "line_id", row.get("line_id"),
|
||||
"object_id", row.get("id"), "name", row.get("name"), "object_type", row.get("object_type"), "line_id", row.get("line_id"),
|
||||
"risk_level", row.get("risk_level"), "status", row.get("status")
|
||||
)));
|
||||
}
|
||||
@@ -1230,9 +1317,10 @@ public class CapabilityCompletionService {
|
||||
Ids.next("wo-action"), workorderId, action, from, to, request.operatorId(),
|
||||
defaultString(request.operatorName(), "现场处置人员"), request.result(), request.comment(),
|
||||
Jsonb.write(request.metadata() == null ? Map.of() : request.metadata()), Timestamp.from(now));
|
||||
if ("closed".equals(to)) {
|
||||
jdbc.update("update alarms set status='closed', updated_at=? where id=?", Timestamp.from(now), workorder.get("alarm_id"));
|
||||
}
|
||||
jdbc.update("""
|
||||
update alarms set status=?,suppressed=false,suppression_reason=null,updated_at=?
|
||||
where id=?
|
||||
""", WorkOrderStateMachine.alarmStatusFor(to), Timestamp.from(now), workorder.get("alarm_id"));
|
||||
publish("workorder.action.completed", Map.of("workorder_id", workorderId, "action", action, "status", to));
|
||||
return Map.of("workorder_id", workorderId, "from_status", from, "status", to, "action", action);
|
||||
}
|
||||
|
||||
+12
-1
@@ -47,10 +47,21 @@ public final class InspectionTaskWorkflow {
|
||||
return PRE_FLIGHT_STATUSES.contains(taskStatus) && !hasMission;
|
||||
}
|
||||
|
||||
public static String routeBindingBlockReason(String taskStatus, boolean hasMission) {
|
||||
if (!PRE_FLIGHT_STATUSES.contains(taskStatus)) {
|
||||
return "只有待执行任务可以配置航线";
|
||||
}
|
||||
if (hasMission) {
|
||||
return "任务已存在飞行任务,不能更换航线";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
public static String taskStatusForMission(String missionStatus) {
|
||||
return switch (missionStatus) {
|
||||
case "FLYING", "PAUSED", "RETURNING" -> "flying";
|
||||
case "COMPLETED", "UPLOADING" -> "data_uploading";
|
||||
case "UPLOADING" -> "data_uploading";
|
||||
case "COMPLETED" -> "completed";
|
||||
case "CANCELLED" -> "cancelled";
|
||||
case "ABORTED", "FAILED", "DISPATCH_FAILED" -> "failed";
|
||||
default -> "dispatched";
|
||||
|
||||
+9
@@ -37,4 +37,13 @@ public final class MissionStateMachine {
|
||||
default -> throw new IllegalArgumentException("不支持的飞行任务命令: " + command);
|
||||
};
|
||||
}
|
||||
|
||||
public static int advanceSimulatedProgress(String currentStatus, int currentProgress) {
|
||||
if (!Set.of("FLYING", "RETURNING").contains(currentStatus)) {
|
||||
throw new IllegalArgumentException("只有飞行中或返航中的模拟任务可以推进进度");
|
||||
}
|
||||
return "RETURNING".equals(currentStatus)
|
||||
? 100
|
||||
: Math.min(100, Math.max(0, currentProgress) + 25);
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -41,4 +41,12 @@ public final class WorkOrderStateMachine {
|
||||
public static String target(String action) {
|
||||
return TARGETS.get(action);
|
||||
}
|
||||
|
||||
public static String alarmStatusFor(String workorderStatus) {
|
||||
return switch (workorderStatus) {
|
||||
case "created", "dispatched" -> "dispatched";
|
||||
case "closed" -> "closed";
|
||||
default -> "processing";
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
+7
-6
@@ -240,7 +240,7 @@ public class DependencyFreeCompletionService {
|
||||
owner_org_id,risk_level,inspection_cycle_days,source_crs,transform_version,
|
||||
accuracy_m,status,created_at,updated_at
|
||||
)
|
||||
select ?,?,?,?,?,?,canonical_geometry,?::jsonb,?,?,?,?,?::numeric,'ACTIVE',?,?
|
||||
select ?,?,?,?,?,?,canonical_geometry,?::jsonb,?,?,?,?,?,?::numeric,'ACTIVE',?,?
|
||||
from inspection_object_import_rows where id=?
|
||||
""",
|
||||
objectId, upper(payload.get("object_type")), payload.get("name"), payload.get("line_id"),
|
||||
@@ -287,15 +287,15 @@ public class DependencyFreeCompletionService {
|
||||
select count(*) from inspection_task_objects where object_id in (%s)
|
||||
""".formatted(placeholders(objectIds.size())), Integer.class, objectIds.toArray());
|
||||
if (referenced > 0) throw conflict("导入对象已被任务引用,不能回滚");
|
||||
jdbc.update("""
|
||||
update inspection_object_import_rows set imported_object_id=null where job_id=?
|
||||
""", jobId);
|
||||
if (!objectIds.isEmpty()) {
|
||||
jdbc.update("delete from inspection_object_versions where object_id in (%s)".formatted(placeholders(objectIds.size())),
|
||||
objectIds.toArray());
|
||||
jdbc.update("delete from inspection_objects where id in (%s)".formatted(placeholders(objectIds.size())),
|
||||
objectIds.toArray());
|
||||
}
|
||||
jdbc.update("""
|
||||
update inspection_object_import_rows set imported_object_id=null where job_id=?
|
||||
""", jobId);
|
||||
jdbc.update("""
|
||||
update inspection_object_import_jobs
|
||||
set status='ROLLED_BACK',confirmed_by=?,updated_at=?
|
||||
@@ -331,7 +331,8 @@ public class DependencyFreeCompletionService {
|
||||
schedule_timezone,next_run_at,effective_to
|
||||
from inspection_plans where id=?
|
||||
""", planId);
|
||||
int limit = Math.max(1, Math.min(20, count));
|
||||
boolean manual = "MANUAL".equalsIgnoreCase(String.valueOf(plan.get("trigger_type")));
|
||||
int limit = manual ? 1 : Math.max(1, Math.min(20, count));
|
||||
Instant cursor = plan.get("next_run_at") == null
|
||||
? Instant.now().truncatedTo(ChronoUnit.HOURS)
|
||||
: ((Timestamp) plan.get("next_run_at")).toInstant();
|
||||
@@ -344,7 +345,7 @@ public class DependencyFreeCompletionService {
|
||||
"scheduled_window_start", start.toString(),
|
||||
"scheduled_window_end", end.toString(),
|
||||
"generation_key", sha256(planId + "|" + start + "|" + end),
|
||||
"source", "SCHEDULE_PREVIEW"
|
||||
"source", manual ? "MANUAL_PREVIEW" : "SCHEDULE_PREVIEW"
|
||||
));
|
||||
cursor = start;
|
||||
}
|
||||
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.ai.trackwalker.operations;
|
||||
|
||||
import com.ai.trackwalker.api.ApiResponse;
|
||||
import org.springframework.web.bind.annotation.GetMapping;
|
||||
import org.springframework.web.bind.annotation.RequestMapping;
|
||||
import org.springframework.web.bind.annotation.RestController;
|
||||
|
||||
@RestController
|
||||
@RequestMapping("/api/v1/operations")
|
||||
public class OperationsHealthController {
|
||||
private final OperationsHealthService healthService;
|
||||
|
||||
public OperationsHealthController(OperationsHealthService healthService) {
|
||||
this.healthService = healthService;
|
||||
}
|
||||
|
||||
@GetMapping("/health")
|
||||
public ApiResponse<?> health() {
|
||||
return ApiResponse.ok(healthService.snapshot());
|
||||
}
|
||||
}
|
||||
+282
@@ -0,0 +1,282 @@
|
||||
package com.ai.trackwalker.operations;
|
||||
|
||||
import com.ai.trackwalker.config.RailProperties;
|
||||
import jakarta.annotation.PreDestroy;
|
||||
import org.apache.kafka.clients.admin.Admin;
|
||||
import org.apache.kafka.clients.admin.AdminClientConfig;
|
||||
import org.apache.kafka.clients.admin.DescribeClusterResult;
|
||||
import org.apache.kafka.common.Node;
|
||||
import org.springframework.boot.web.client.RestTemplateBuilder;
|
||||
import org.springframework.beans.factory.annotation.Autowired;
|
||||
import org.springframework.data.redis.core.RedisCallback;
|
||||
import org.springframework.data.redis.core.StringRedisTemplate;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.kafka.core.KafkaAdmin;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.web.client.RestTemplate;
|
||||
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collection;
|
||||
import java.util.HashMap;
|
||||
import java.util.LinkedHashMap;
|
||||
import java.util.List;
|
||||
import java.util.Locale;
|
||||
import java.util.Map;
|
||||
import java.util.Objects;
|
||||
import java.util.concurrent.CompletableFuture;
|
||||
import java.util.concurrent.ExecutorService;
|
||||
import java.util.concurrent.Executors;
|
||||
import java.util.concurrent.ThreadFactory;
|
||||
import java.util.concurrent.TimeUnit;
|
||||
import java.util.concurrent.atomic.AtomicInteger;
|
||||
|
||||
@Service
|
||||
public class OperationsHealthService {
|
||||
private static final Duration HTTP_TIMEOUT = Duration.ofSeconds(3);
|
||||
private static final long KAFKA_TIMEOUT_SECONDS = 3;
|
||||
|
||||
private final List<ProbeDefinition> probes;
|
||||
private final ExecutorService probeExecutor;
|
||||
|
||||
@Autowired
|
||||
public OperationsHealthService(
|
||||
JdbcTemplate jdbc,
|
||||
StringRedisTemplate redis,
|
||||
KafkaAdmin kafkaAdmin,
|
||||
RailProperties properties,
|
||||
RestTemplateBuilder restTemplateBuilder
|
||||
) {
|
||||
RestTemplate http = restTemplateBuilder
|
||||
.setConnectTimeout(HTTP_TIMEOUT)
|
||||
.setReadTimeout(HTTP_TIMEOUT)
|
||||
.build();
|
||||
this.probes = List.of(
|
||||
definition("platform", "业务平台", "/actuator/health", "服务内自检",
|
||||
() -> up("运维探测接口已响应")),
|
||||
definition("postgis", "PostGIS", "PostgreSQL / PostGIS", "SQL 探测",
|
||||
() -> up("PostGIS " + jdbc.queryForObject("select PostGIS_Version()", String.class))),
|
||||
definition("redis", "Redis", redisEndpoint(), "PING",
|
||||
() -> {
|
||||
String pong = redis.execute((RedisCallback<String>) connection -> connection.ping());
|
||||
if (!"PONG".equalsIgnoreCase(pong)) {
|
||||
throw new IllegalStateException("PING 未返回 PONG");
|
||||
}
|
||||
return up("PING=PONG");
|
||||
}),
|
||||
definition("kafka", "Kafka", kafkaEndpoint(kafkaAdmin), "集群元数据",
|
||||
() -> kafkaProbe(kafkaAdmin)),
|
||||
definition("minio", "MinIO 对象存储", properties.getStorage().getMinioEndpoint(), "HTTP liveness",
|
||||
() -> httpProbe(http, properties.getStorage().getMinioEndpoint(), "/minio/health/live")),
|
||||
definition("vision", "视觉推理服务", properties.getAi().getVisionUrl(), "HTTP /health",
|
||||
() -> httpProbe(http, properties.getAi().getVisionUrl(), "/health")),
|
||||
definition("pointcloud", "点云分析服务", properties.getAi().getPointcloudUrl(), "HTTP /health",
|
||||
() -> httpProbe(http, properties.getAi().getPointcloudUrl(), "/health")),
|
||||
definition("artifact-installer", "模型安装代理", properties.getArtifactInstaller().getUrl(), "HTTP /health",
|
||||
() -> httpProbe(http, properties.getArtifactInstaller().getUrl(), "/health")),
|
||||
definition("uav-access", "无人机接入服务", properties.getUavAccess().getUrl(), "Actuator /health",
|
||||
() -> httpProbe(http, properties.getUavAccess().getUrl(), "/actuator/health"))
|
||||
);
|
||||
this.probeExecutor = newProbeExecutor();
|
||||
}
|
||||
|
||||
OperationsHealthService(List<ProbeDefinition> probes) {
|
||||
this.probes = List.copyOf(probes);
|
||||
this.probeExecutor = newProbeExecutor();
|
||||
}
|
||||
|
||||
public Map<String, Object> snapshot() {
|
||||
Instant checkedAt = Instant.now();
|
||||
List<CompletableFuture<Map<String, Object>>> futures = probes.stream()
|
||||
.map(probe -> CompletableFuture.supplyAsync(() -> runProbe(probe, checkedAt), probeExecutor))
|
||||
.toList();
|
||||
List<Map<String, Object>> services = futures.stream().map(CompletableFuture::join).toList();
|
||||
Map<String, Map<String, Object>> byCode = new HashMap<>();
|
||||
services.forEach(service -> byCode.put(String.valueOf(service.get("code")), service));
|
||||
|
||||
String overall = services.stream().allMatch(service -> "UP".equals(service.get("status")))
|
||||
? "UP"
|
||||
: "DEGRADED";
|
||||
return Map.of(
|
||||
"status", overall,
|
||||
"checked_at", checkedAt,
|
||||
"services", services,
|
||||
"integrations", integrations(byCode)
|
||||
);
|
||||
}
|
||||
|
||||
@PreDestroy
|
||||
public void shutdown() {
|
||||
probeExecutor.shutdownNow();
|
||||
}
|
||||
|
||||
static ProbeDefinition definition(
|
||||
String code,
|
||||
String name,
|
||||
String endpoint,
|
||||
String check,
|
||||
CheckedProbe probe
|
||||
) {
|
||||
return new ProbeDefinition(code, name, endpoint, check, probe);
|
||||
}
|
||||
|
||||
static ProbeOutcome up(String detail) {
|
||||
return new ProbeOutcome("UP", detail);
|
||||
}
|
||||
|
||||
static ProbeOutcome down(String detail) {
|
||||
return new ProbeOutcome("DOWN", detail);
|
||||
}
|
||||
|
||||
private Map<String, Object> runProbe(ProbeDefinition definition, Instant checkedAt) {
|
||||
long started = System.nanoTime();
|
||||
ProbeOutcome outcome;
|
||||
try {
|
||||
outcome = Objects.requireNonNull(definition.probe().check(), "探测没有返回结果");
|
||||
} catch (Exception error) {
|
||||
outcome = down(concise(error));
|
||||
}
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("code", definition.code());
|
||||
row.put("name", definition.name());
|
||||
row.put("endpoint", definition.endpoint());
|
||||
row.put("check", definition.check());
|
||||
row.put("status", outcome.status());
|
||||
row.put("status_text", "UP".equals(outcome.status()) ? "运行正常" : "连接异常");
|
||||
row.put("detail", outcome.detail());
|
||||
row.put("latency_ms", TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - started));
|
||||
row.put("checked_at", checkedAt);
|
||||
return row;
|
||||
}
|
||||
|
||||
private List<Map<String, Object>> integrations(Map<String, Map<String, Object>> services) {
|
||||
List<Map<String, Object>> rows = new ArrayList<>();
|
||||
rows.add(integration(
|
||||
"uav-task", "无人机任务接口", "SDK/API", "/api/v1/uav/callbacks/mission-status",
|
||||
"航线任务和飞行状态接入", "uav-access", services
|
||||
));
|
||||
rows.add(integration(
|
||||
"resource-ingest", "多源数据接入", "REST", "/api/v1/inspection/resources/complete",
|
||||
"资源清单与接入完成通知", "platform", services
|
||||
));
|
||||
rows.add(integration(
|
||||
"workorder-callback", "工单状态回调", "REST", "/api/v1/workorders/callbacks/status",
|
||||
"工单处置状态同步", "platform", services
|
||||
));
|
||||
rows.add(integration(
|
||||
"event-bus", "无人机事件总线", "Kafka", "uav.access.events.v1",
|
||||
"无人机状态和遥测事件投影", "kafka", services
|
||||
));
|
||||
return rows;
|
||||
}
|
||||
|
||||
private Map<String, Object> integration(
|
||||
String code,
|
||||
String name,
|
||||
String type,
|
||||
String endpoint,
|
||||
String description,
|
||||
String dependencyCode,
|
||||
Map<String, Map<String, Object>> services
|
||||
) {
|
||||
Map<String, Object> dependency = services.get(dependencyCode);
|
||||
String dependencyStatus = dependency == null ? "UNKNOWN" : String.valueOf(dependency.get("status"));
|
||||
boolean available = "UP".equals(dependencyStatus);
|
||||
Map<String, Object> row = new LinkedHashMap<>();
|
||||
row.put("code", code);
|
||||
row.put("name", name);
|
||||
row.put("type", type);
|
||||
row.put("endpoint", endpoint);
|
||||
row.put("description", description);
|
||||
row.put("status", available ? "AVAILABLE" : "UNAVAILABLE");
|
||||
row.put("status_text", available
|
||||
? ("platform".equals(dependencyCode) ? "接口已提供" : "依赖可用")
|
||||
: (dependency == null ? "未探测" : "依赖异常"));
|
||||
row.put("evidence", dependency == null
|
||||
? "缺少 " + dependencyCode + " 探测结果"
|
||||
: "基于“" + dependency.get("name") + "”实时探测:" + dependency.get("detail"));
|
||||
return row;
|
||||
}
|
||||
|
||||
private static ProbeOutcome httpProbe(RestTemplate http, String baseUrl, String path) {
|
||||
if (baseUrl == null || baseUrl.isBlank()) {
|
||||
return down("服务地址未配置");
|
||||
}
|
||||
String url = baseUrl.endsWith("/")
|
||||
? baseUrl.substring(0, baseUrl.length() - 1) + path
|
||||
: baseUrl + path;
|
||||
ResponseEntity<Map> response = http.getForEntity(url, Map.class);
|
||||
if (!response.getStatusCode().is2xxSuccessful()) {
|
||||
return down("HTTP " + response.getStatusCode().value());
|
||||
}
|
||||
Map<?, ?> body = response.getBody();
|
||||
Object reportedValue = body == null ? null : body.get("status");
|
||||
String reported = reportedValue == null ? "" : String.valueOf(reportedValue);
|
||||
String normalized = reported.toUpperCase(Locale.ROOT);
|
||||
if (List.of("DOWN", "OUT_OF_SERVICE", "UNAVAILABLE", "FAILED").contains(normalized)) {
|
||||
return down("服务报告状态 " + reported);
|
||||
}
|
||||
return up(reported.isBlank() ? "HTTP " + response.getStatusCode().value() : "服务报告状态 " + reported);
|
||||
}
|
||||
|
||||
private static ProbeOutcome kafkaProbe(KafkaAdmin kafkaAdmin) throws Exception {
|
||||
Map<String, Object> configuration = new HashMap<>(kafkaAdmin.getConfigurationProperties());
|
||||
configuration.put(AdminClientConfig.REQUEST_TIMEOUT_MS_CONFIG, (int) Duration.ofSeconds(KAFKA_TIMEOUT_SECONDS).toMillis());
|
||||
configuration.put(AdminClientConfig.DEFAULT_API_TIMEOUT_MS_CONFIG, (int) Duration.ofSeconds(KAFKA_TIMEOUT_SECONDS).toMillis());
|
||||
Admin admin = Admin.create(configuration);
|
||||
try {
|
||||
DescribeClusterResult cluster = admin.describeCluster();
|
||||
Collection<Node> nodes = cluster.nodes().get(KAFKA_TIMEOUT_SECONDS, TimeUnit.SECONDS);
|
||||
if (nodes.isEmpty()) {
|
||||
return down("集群未返回 broker");
|
||||
}
|
||||
return up("可用 broker " + nodes.size() + " 个");
|
||||
} finally {
|
||||
admin.close(Duration.ZERO);
|
||||
}
|
||||
}
|
||||
|
||||
private static String redisEndpoint() {
|
||||
String host = System.getenv().getOrDefault("SPRING_REDIS_HOST", "localhost");
|
||||
String port = System.getenv().getOrDefault("SPRING_REDIS_PORT", "6379");
|
||||
return host + ":" + port;
|
||||
}
|
||||
|
||||
private static String kafkaEndpoint(KafkaAdmin kafkaAdmin) {
|
||||
Object value = kafkaAdmin.getConfigurationProperties().get(AdminClientConfig.BOOTSTRAP_SERVERS_CONFIG);
|
||||
return value == null ? "未配置" : String.valueOf(value);
|
||||
}
|
||||
|
||||
private static ExecutorService newProbeExecutor() {
|
||||
AtomicInteger sequence = new AtomicInteger();
|
||||
ThreadFactory factory = task -> {
|
||||
Thread thread = new Thread(task, "operations-health-" + sequence.incrementAndGet());
|
||||
thread.setDaemon(true);
|
||||
return thread;
|
||||
};
|
||||
return Executors.newFixedThreadPool(8, factory);
|
||||
}
|
||||
|
||||
private static String concise(Exception error) {
|
||||
Throwable current = error;
|
||||
while (current.getCause() != null && current.getCause() != current) {
|
||||
current = current.getCause();
|
||||
}
|
||||
String message = current.getMessage();
|
||||
String text = current.getClass().getSimpleName() + (message == null || message.isBlank() ? "" : ": " + message);
|
||||
return text.length() > 220 ? text.substring(0, 220) : text;
|
||||
}
|
||||
|
||||
record ProbeDefinition(String code, String name, String endpoint, String check, CheckedProbe probe) {
|
||||
}
|
||||
|
||||
record ProbeOutcome(String status, String detail) {
|
||||
}
|
||||
|
||||
@FunctionalInterface
|
||||
interface CheckedProbe {
|
||||
ProbeOutcome check() throws Exception;
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,17 @@ package com.ai.trackwalker.service;
|
||||
|
||||
import com.ai.trackwalker.api.dto.Requests;
|
||||
import com.ai.trackwalker.capability.uav.InspectionTaskWorkflow;
|
||||
import com.ai.trackwalker.capability.workorder.WorkOrderStateMachine;
|
||||
import com.ai.trackwalker.common.Ids;
|
||||
import com.ai.trackwalker.common.Jsonb;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
import org.springframework.transaction.support.TransactionTemplate;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
@@ -23,12 +27,21 @@ public class PlatformService {
|
||||
private final AiClient aiClient;
|
||||
private final RuleEngine ruleEngine;
|
||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||
private final TransactionTemplate transactionTemplate;
|
||||
|
||||
public PlatformService(JdbcTemplate jdbc, AiClient aiClient, RuleEngine ruleEngine, KafkaTemplate<String, Object> kafkaTemplate) {
|
||||
public PlatformService(
|
||||
JdbcTemplate jdbc,
|
||||
AiClient aiClient,
|
||||
RuleEngine ruleEngine,
|
||||
KafkaTemplate<String, Object> kafkaTemplate,
|
||||
PlatformTransactionManager transactionManager
|
||||
) {
|
||||
this.jdbc = jdbc;
|
||||
this.aiClient = aiClient;
|
||||
this.ruleEngine = ruleEngine;
|
||||
this.kafkaTemplate = kafkaTemplate;
|
||||
this.transactionTemplate = new TransactionTemplate(transactionManager);
|
||||
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -150,12 +163,23 @@ public class PlatformService {
|
||||
""");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> completeResources(Requests.ResourceCompleteRequest request) {
|
||||
List<String> resourceIds = ingestResources(request);
|
||||
Map<String, Object> job = createAnalysisJob(new Requests.CreateAnalysisJobRequest(request.taskId(), resourceIds, null, "offline", "normal"));
|
||||
runAnalysis(String.valueOf(job.get("analysis_job_id")));
|
||||
return Map.of("accepted", resourceIds.size(), "analysis_job_id", job.get("analysis_job_id"));
|
||||
Map<String, Object> prepared = transactionTemplate.execute(ignored -> {
|
||||
List<String> resourceIds = ingestResources(request);
|
||||
Map<String, Object> job = createAnalysisJob(new Requests.CreateAnalysisJobRequest(
|
||||
request.taskId(), resourceIds, null, "offline", "normal"));
|
||||
return Map.of(
|
||||
"accepted", resourceIds.size(),
|
||||
"analysis_job_id", job.get("analysis_job_id")
|
||||
);
|
||||
});
|
||||
if (prepared == null) {
|
||||
throw new IllegalStateException("资源接入事务未返回结果");
|
||||
}
|
||||
Map<String, Object> analysis = runAnalysis(String.valueOf(prepared.get("analysis_job_id")));
|
||||
Map<String, Object> response = new HashMap<>(prepared);
|
||||
response.put("analysis_status", analysis.get("status"));
|
||||
return response;
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -224,16 +248,77 @@ public class PlatformService {
|
||||
""");
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> runAnalysis(String jobId) {
|
||||
Map<String, Object> job = jdbc.queryForMap("select *, resource_ids::text as resource_ids_text, scene_set::text as scene_set_text from analysis_jobs where id=?", jobId);
|
||||
AnalysisRunClaim claim = transactionTemplate.execute(ignored -> claimAnalysisRun(jobId));
|
||||
if (claim == null) {
|
||||
throw new IllegalStateException("分析任务状态事务未返回结果");
|
||||
}
|
||||
if (!claim.shouldExecute()) {
|
||||
return currentAnalysisResponse(jobId, claim.job());
|
||||
}
|
||||
|
||||
try {
|
||||
Map<String, Object> result = transactionTemplate.execute(ignored -> executeAnalysis(jobId, claim.job()));
|
||||
if (result == null) {
|
||||
throw new IllegalStateException("分析执行事务未返回结果");
|
||||
}
|
||||
return result;
|
||||
} catch (RuntimeException failure) {
|
||||
try {
|
||||
transactionTemplate.executeWithoutResult(ignored -> markAnalysisFailed(jobId, failure));
|
||||
} catch (RuntimeException persistenceFailure) {
|
||||
failure.addSuppressed(persistenceFailure);
|
||||
}
|
||||
if (failure instanceof ResponseStatusException responseStatusException) {
|
||||
throw responseStatusException;
|
||||
}
|
||||
throw new ResponseStatusException(
|
||||
HttpStatus.INTERNAL_SERVER_ERROR,
|
||||
"分析任务执行失败:" + failureMessage(failure),
|
||||
failure
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private AnalysisRunClaim claimAnalysisRun(String jobId) {
|
||||
List<Map<String, Object>> rows = jdbc.queryForList("""
|
||||
select id, task_id, resource_ids::text as resource_ids_text,
|
||||
scene_set::text as scene_set_text, status, summary::text as summary_text
|
||||
from analysis_jobs
|
||||
where id=?
|
||||
for update
|
||||
""", jobId);
|
||||
if (rows.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "分析任务不存在");
|
||||
}
|
||||
Map<String, Object> job = rows.get(0);
|
||||
String status = String.valueOf(job.get("status"));
|
||||
if ("running".equals(status) || "completed".equals(status)) {
|
||||
return new AnalysisRunClaim(false, job);
|
||||
}
|
||||
if (!"queued".equals(status) && !"failed".equals(status)) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "当前分析任务状态不允许执行:" + status);
|
||||
}
|
||||
int updated = jdbc.update("""
|
||||
update analysis_jobs
|
||||
set status='running', summary='{}'::jsonb, completed_at=null
|
||||
where id=? and status=?
|
||||
""", jobId, status);
|
||||
if (updated != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "分析任务状态已变化,请刷新后重试");
|
||||
}
|
||||
job.put("status", "running");
|
||||
job.put("summary_text", "{}");
|
||||
return new AnalysisRunClaim(true, job);
|
||||
}
|
||||
|
||||
private Map<String, Object> executeAnalysis(String jobId, Map<String, Object> job) {
|
||||
Map<String, Object> task = taskById(String.valueOf(job.get("task_id")));
|
||||
List<String> resourceIds = Jsonb.stringList(String.valueOf(job.get("resource_ids_text")));
|
||||
List<String> scenes = Jsonb.stringList(String.valueOf(job.get("scene_set_text")));
|
||||
int aiResults = 0;
|
||||
int alarms = 0;
|
||||
int suppressed = 0;
|
||||
jdbc.update("update analysis_jobs set status='running' where id=?", jobId);
|
||||
for (String resourceId : resourceIds) {
|
||||
Map<String, Object> resource = resourceById(resourceId);
|
||||
List<Map<String, Object>> results = aiClient.analyze(resource, scenes);
|
||||
@@ -251,23 +336,86 @@ public class PlatformService {
|
||||
}
|
||||
}
|
||||
Map<String, Object> summary = Map.of("ai_results", aiResults, "alarms", alarms, "suppressed", suppressed);
|
||||
jdbc.update("update analysis_jobs set status='completed', summary=?::jsonb, completed_at=? where id=?", Jsonb.write(summary), Timestamp.from(Instant.now()), jobId);
|
||||
int completed = jdbc.update("""
|
||||
update analysis_jobs
|
||||
set status='completed', summary=?::jsonb, completed_at=?
|
||||
where id=? and status='running'
|
||||
""", Jsonb.write(summary), Timestamp.from(Instant.now()), jobId);
|
||||
if (completed != 1) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "分析任务状态已变化,结果未提交");
|
||||
}
|
||||
jdbc.update("update inspection_tasks set status='completed', updated_at=? where id=?", Timestamp.from(Instant.now()), task.get("id"));
|
||||
return Map.of("analysis_job_id", jobId, "status", "completed", "summary", summary);
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listAlarms(boolean includeSuppressed) {
|
||||
String sql = "select id as alarm_id, task_id, result_id, scene, category, severity, confidence, location::text as location, evidence::text as evidence, rule_hits::text as rule_hits, status, suppressed, suppression_reason, created_at, updated_at from alarms";
|
||||
if (!includeSuppressed) {
|
||||
sql += " where suppressed=false";
|
||||
private Map<String, Object> currentAnalysisResponse(String jobId, Map<String, Object> job) {
|
||||
Map<String, Object> response = new HashMap<>();
|
||||
response.put("analysis_job_id", jobId);
|
||||
response.put("status", job.get("status"));
|
||||
response.put("summary", Jsonb.map(String.valueOf(job.getOrDefault("summary_text", "{}"))));
|
||||
response.put("idempotent", true);
|
||||
return response;
|
||||
}
|
||||
|
||||
private void markAnalysisFailed(String jobId, RuntimeException failure) {
|
||||
Map<String, Object> summary = Map.of(
|
||||
"error", failureMessage(failure),
|
||||
"failed_at", Instant.now().toString()
|
||||
);
|
||||
jdbc.update("""
|
||||
update analysis_jobs
|
||||
set status='failed', summary=?::jsonb, completed_at=?
|
||||
where id=? and status='running'
|
||||
""", Jsonb.write(summary), Timestamp.from(Instant.now()), jobId);
|
||||
}
|
||||
|
||||
private String failureMessage(Throwable failure) {
|
||||
Throwable cause = failure;
|
||||
while (cause.getCause() != null && cause.getCause() != cause) {
|
||||
cause = cause.getCause();
|
||||
}
|
||||
sql += " order by created_at desc";
|
||||
String message = cause.getMessage();
|
||||
if (message == null || message.isBlank()) {
|
||||
message = cause.getClass().getSimpleName();
|
||||
}
|
||||
return message.length() > 1000 ? message.substring(0, 1000) : message;
|
||||
}
|
||||
|
||||
private record AnalysisRunClaim(boolean shouldExecute, Map<String, Object> job) {
|
||||
}
|
||||
|
||||
public List<Map<String, Object>> listAlarms(boolean includeSuppressed) {
|
||||
String sql = """
|
||||
select a.id as alarm_id,a.task_id,a.result_id,a.scene,a.category,a.severity,a.confidence,
|
||||
a.location::text as location,a.evidence::text as evidence,a.rule_hits::text as rule_hits,
|
||||
case when a.suppressed then 'suppressed'
|
||||
when wo.status is null then a.status
|
||||
when wo.status in ('created','dispatched') then 'dispatched'
|
||||
when wo.status='closed' then 'closed'
|
||||
else 'processing' end as status,
|
||||
a.suppressed,a.suppression_reason,a.created_at,a.updated_at
|
||||
from alarms a
|
||||
left join lateral (
|
||||
select status from work_orders where alarm_id=a.id order by created_at desc limit 1
|
||||
) wo on true
|
||||
""";
|
||||
if (!includeSuppressed) {
|
||||
sql += " where a.suppressed=false";
|
||||
}
|
||||
sql += " order by a.created_at desc";
|
||||
return jdbc.queryForList(sql);
|
||||
}
|
||||
|
||||
@Transactional
|
||||
public Map<String, Object> decideAlarm(String alarmId, Requests.AlarmDecisionRequest request) {
|
||||
String action = request.action() == null ? "confirm" : request.action();
|
||||
List<Map<String, Object>> linkedWorkorders = jdbc.queryForList(
|
||||
"select id,status from work_orders where alarm_id=? order by created_at desc limit 1",
|
||||
alarmId
|
||||
);
|
||||
if ("suppress".equals(action) && !linkedWorkorders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.CONFLICT, "告警已生成处置工单,请在工单中完成闭环");
|
||||
}
|
||||
String status;
|
||||
boolean suppressed;
|
||||
String reason = request.reason();
|
||||
@@ -277,11 +425,15 @@ public class PlatformService {
|
||||
suppressed = true;
|
||||
}
|
||||
case "reopen" -> {
|
||||
status = "pending";
|
||||
status = linkedWorkorders.isEmpty()
|
||||
? "pending"
|
||||
: WorkOrderStateMachine.alarmStatusFor(String.valueOf(linkedWorkorders.get(0).get("status")));
|
||||
suppressed = false;
|
||||
}
|
||||
default -> {
|
||||
status = "confirmed";
|
||||
status = linkedWorkorders.isEmpty()
|
||||
? "confirmed"
|
||||
: WorkOrderStateMachine.alarmStatusFor(String.valueOf(linkedWorkorders.get(0).get("status")));
|
||||
suppressed = false;
|
||||
}
|
||||
}
|
||||
@@ -326,17 +478,21 @@ public class PlatformService {
|
||||
? jdbc.queryForMap("select id, alarm_id from work_orders where alarm_id=?", request.alarmId())
|
||||
: jdbc.queryForMap("select id, alarm_id from work_orders where id=?", request.workorderId());
|
||||
String workorderId = String.valueOf(workorder.get("id"));
|
||||
jdbc.update("update work_orders set status=?, close_result=?, comment=?, updated_at=? where alarm_id=?",
|
||||
String alarmId = String.valueOf(workorder.get("alarm_id"));
|
||||
jdbc.update("update work_orders set status=?, close_result=?, comment=?, updated_at=? where id=?",
|
||||
request.status() == null ? "closed" : request.status(),
|
||||
request.result(),
|
||||
request.comment(),
|
||||
Timestamp.from(Instant.now()),
|
||||
request.alarmId());
|
||||
String alarmStatus = "closed".equals(request.status()) || request.status() == null ? "closed" : "processing";
|
||||
jdbc.update("update alarms set status=?, updated_at=? where id=?", alarmStatus, Timestamp.from(Instant.now()), request.alarmId());
|
||||
workorderId);
|
||||
String alarmStatus = WorkOrderStateMachine.alarmStatusFor(
|
||||
request.status() == null ? "closed" : request.status()
|
||||
);
|
||||
jdbc.update("update alarms set status=?,suppressed=false,suppression_reason=null,updated_at=? where id=?",
|
||||
alarmStatus, Timestamp.from(Instant.now()), alarmId);
|
||||
insertWorkOrderOperation(workorderId, "status_callback", request.operator(), request.result(), request.comment());
|
||||
publish("workorder.closed", Map.of("alarm_id", request.alarmId(), "result", request.result()));
|
||||
return Map.of("workorder_id", workorderId, "alarm_id", request.alarmId(), "status", request.status() == null ? "closed" : request.status());
|
||||
publish("workorder.closed", Map.of("alarm_id", alarmId, "result", request.result()));
|
||||
return Map.of("workorder_id", workorderId, "alarm_id", alarmId, "status", request.status() == null ? "closed" : request.status());
|
||||
}
|
||||
|
||||
@Transactional
|
||||
@@ -347,8 +503,8 @@ public class PlatformService {
|
||||
Instant now = Instant.now();
|
||||
jdbc.update("update work_orders set status=?, close_result=?, comment=?, updated_at=? where id=?",
|
||||
targetStatus, request.result(), request.comment(), Timestamp.from(now), workorderId);
|
||||
jdbc.update("update alarms set status=?, updated_at=? where id=?",
|
||||
"closed".equals(targetStatus) ? "closed" : "processing", Timestamp.from(now), workorder.get("alarm_id"));
|
||||
jdbc.update("update alarms set status=?,suppressed=false,suppression_reason=null,updated_at=? where id=?",
|
||||
WorkOrderStateMachine.alarmStatusFor(targetStatus), Timestamp.from(now), workorder.get("alarm_id"));
|
||||
insertWorkOrderOperation(workorderId, "return".equals(action) ? "review_returned" : "review_approved",
|
||||
request.operator(), request.result(), request.comment());
|
||||
publish("workorder.reviewed", Map.of("workorder_id", workorderId, "status", targetStatus));
|
||||
@@ -358,11 +514,21 @@ public class PlatformService {
|
||||
@Transactional
|
||||
public Map<String, Object> redispatchWorkOrder(String workorderId, String assignee, String reason, String operator) {
|
||||
String resolvedAssignee = assignee == null || assignee.isBlank() ? "专业复核人员" : assignee;
|
||||
List<Map<String, Object>> workorders = jdbc.queryForList(
|
||||
"select id,alarm_id from work_orders where id=?",
|
||||
workorderId
|
||||
);
|
||||
if (workorders.isEmpty()) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "工单不存在");
|
||||
}
|
||||
Map<String, Object> workorder = workorders.get(0);
|
||||
int updated = jdbc.update("update work_orders set status='created', assignee=?, comment=?, updated_at=? where id=?",
|
||||
resolvedAssignee, reason, Timestamp.from(Instant.now()), workorderId);
|
||||
if (updated == 0) {
|
||||
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "工单不存在");
|
||||
}
|
||||
jdbc.update("update alarms set status='dispatched',suppressed=false,suppression_reason=null,updated_at=? where id=?",
|
||||
Timestamp.from(Instant.now()), workorder.get("alarm_id"));
|
||||
insertWorkOrderOperation(workorderId, "redispatched", operator, "重新派发", reason);
|
||||
publish("workorder.redispatched", Map.of("workorder_id", workorderId, "assignee", resolvedAssignee));
|
||||
return Map.of("workorder_id", workorderId, "status", "created", "assignee", resolvedAssignee);
|
||||
@@ -412,7 +578,10 @@ public class PlatformService {
|
||||
alarm.put("evidence", Jsonb.map(String.valueOf(alarm.get("evidence"))));
|
||||
alarm.put("rule_hits", Jsonb.stringList(String.valueOf(alarm.get("rule_hits"))));
|
||||
Map<String, Object> result = resultEvidence(String.valueOf(alarm.get("result_id")));
|
||||
List<Map<String, Object>> relatedWorkorders = jdbc.queryForList("select id as workorder_id, status, assignee, close_result, comment, created_at, updated_at from work_orders where alarm_id=?", alarmId);
|
||||
List<Map<String, Object>> relatedWorkorders = jdbc.queryForList("select id as workorder_id, status, assignee, close_result, comment, created_at, updated_at from work_orders where alarm_id=? order by updated_at desc", alarmId);
|
||||
if (!Boolean.TRUE.equals(alarm.get("suppressed")) && !relatedWorkorders.isEmpty()) {
|
||||
alarm.put("status", WorkOrderStateMachine.alarmStatusFor(String.valueOf(relatedWorkorders.get(0).get("status"))));
|
||||
}
|
||||
return Map.of("alarm", alarm, "ai_result", result, "workorders", relatedWorkorders);
|
||||
}
|
||||
|
||||
@@ -541,7 +710,7 @@ public class PlatformService {
|
||||
Jsonb.write(decision.location()),
|
||||
Jsonb.write(Map.of("resource_id", resource.get("id"), "storage_url", resource.get("storage_url"))),
|
||||
Jsonb.write(decision.ruleHits()),
|
||||
decision.suppressed() ? "suppressed" : "pending",
|
||||
decision.suppressed() ? "suppressed" : "dispatched",
|
||||
decision.suppressed(),
|
||||
decision.suppressionReason(),
|
||||
ownerOrgId,
|
||||
|
||||
@@ -27,6 +27,7 @@ spring:
|
||||
properties:
|
||||
spring.json.trusted.packages: "*"
|
||||
spring.json.use.type.headers: false
|
||||
spring.json.value.default.type: java.util.LinkedHashMap
|
||||
producer:
|
||||
key-serializer: org.apache.kafka.common.serialization.StringSerializer
|
||||
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
|
||||
|
||||
+18
-1
@@ -36,7 +36,24 @@ class MissionStateMachineTest {
|
||||
void taskStatusFollowsMissionLifecycle() {
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("DISPATCHED")).isEqualTo("dispatched");
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("PAUSED")).isEqualTo("flying");
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("COMPLETED")).isEqualTo("data_uploading");
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("UPLOADING")).isEqualTo("data_uploading");
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("COMPLETED")).isEqualTo("completed");
|
||||
assertThat(InspectionTaskWorkflow.taskStatusForMission("ABORTED")).isEqualTo("failed");
|
||||
}
|
||||
|
||||
@Test
|
||||
void routeCanOnlyBeBoundBeforeFlightStarts() {
|
||||
assertThat(InspectionTaskWorkflow.routeBindingBlockReason("created", false)).isNull();
|
||||
assertThat(InspectionTaskWorkflow.routeBindingBlockReason("pending", false)).isNull();
|
||||
assertThat(InspectionTaskWorkflow.routeBindingBlockReason("flying", false)).isNotNull();
|
||||
assertThat(InspectionTaskWorkflow.routeBindingBlockReason("created", true)).isNotNull();
|
||||
}
|
||||
|
||||
@Test
|
||||
void returningSimulatorCanAdvanceDirectlyToCompletion() {
|
||||
assertThat(MissionStateMachine.advanceSimulatedProgress("FLYING", 50)).isEqualTo(75);
|
||||
assertThat(MissionStateMachine.advanceSimulatedProgress("RETURNING", 50)).isEqualTo(100);
|
||||
assertThatThrownBy(() -> MissionStateMachine.advanceSimulatedProgress("PAUSED", 50))
|
||||
.isInstanceOf(IllegalArgumentException.class);
|
||||
}
|
||||
}
|
||||
|
||||
+8
@@ -22,4 +22,12 @@ class WorkOrderStateMachineTest {
|
||||
assertThat(WorkOrderStateMachine.canApply("closed", "RETURN")).isFalse();
|
||||
assertThat(WorkOrderStateMachine.supports("UNKNOWN")).isFalse();
|
||||
}
|
||||
|
||||
@Test
|
||||
void mapsWorkorderLifecycleBackToAlarmStatus() {
|
||||
assertThat(WorkOrderStateMachine.alarmStatusFor("created")).isEqualTo("dispatched");
|
||||
assertThat(WorkOrderStateMachine.alarmStatusFor("accepted")).isEqualTo("processing");
|
||||
assertThat(WorkOrderStateMachine.alarmStatusFor("submitted")).isEqualTo("processing");
|
||||
assertThat(WorkOrderStateMachine.alarmStatusFor("closed")).isEqualTo("closed");
|
||||
}
|
||||
}
|
||||
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
package com.ai.trackwalker.foundation.service;
|
||||
|
||||
import com.ai.trackwalker.capability.service.CapabilityCompletionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class DependencyFreeCompletionServiceObjectImportTest {
|
||||
|
||||
@Test
|
||||
void commitUsesOneJdbcArgumentForEveryInspectionObjectPlaceholder() {
|
||||
ObjectImportJdbcTemplate jdbc = new ObjectImportJdbcTemplate();
|
||||
DependencyFreeCompletionService service = service(jdbc);
|
||||
|
||||
Map<String, Object> result = service.commitObjectImport("import-job-123456789012", "reviewer-1");
|
||||
|
||||
assertThat(result).containsEntry("status", "COMMITTED");
|
||||
assertThat((List<?>) result.get("object_ids")).hasSize(1);
|
||||
UpdateCall objectInsert = jdbc.updateCalls.stream()
|
||||
.filter(call -> call.sql().contains("insert into inspection_objects"))
|
||||
.findFirst()
|
||||
.orElseThrow();
|
||||
assertThat(questionMarks(objectInsert.sql())).isEqualTo(16);
|
||||
assertThat(objectInsert.arguments()).hasSize(16);
|
||||
assertThat(objectInsert.arguments()[11]).isEqualTo("DEMO_TRANSFORM_V1");
|
||||
assertThat(objectInsert.arguments()[12]).isEqualTo(5.0);
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("insert into inspection_object_versions"));
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("set status='COMMITTED'"));
|
||||
assertPlaceholderCountsMatch(jdbc);
|
||||
}
|
||||
|
||||
@Test
|
||||
void rollbackRemovesImportedObjectsAndClearsImportLinks() {
|
||||
ObjectImportJdbcTemplate jdbc = new ObjectImportJdbcTemplate();
|
||||
DependencyFreeCompletionService service = service(jdbc);
|
||||
|
||||
Map<String, Object> result = service.rollbackObjectImport("import-job-1", "reviewer-1");
|
||||
|
||||
assertThat(result)
|
||||
.containsEntry("status", "ROLLED_BACK")
|
||||
.containsEntry("rolled_back_objects", 1);
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("delete from inspection_object_versions"));
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("delete from inspection_objects"));
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("set imported_object_id=null"));
|
||||
assertThat(jdbc.updateCalls).anyMatch(call -> call.sql().contains("set status='ROLLED_BACK'"));
|
||||
assertThat(updateIndex(jdbc, "set imported_object_id=null"))
|
||||
.as("import-row foreign keys must be cleared before deleting imported objects")
|
||||
.isLessThan(updateIndex(jdbc, "delete from inspection_objects"));
|
||||
assertPlaceholderCountsMatch(jdbc);
|
||||
}
|
||||
|
||||
private int updateIndex(ObjectImportJdbcTemplate jdbc, String sqlFragment) {
|
||||
for (int index = 0; index < jdbc.updateCalls.size(); index++) {
|
||||
if (jdbc.updateCalls.get(index).sql().contains(sqlFragment)) return index;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
private void assertPlaceholderCountsMatch(ObjectImportJdbcTemplate jdbc) {
|
||||
assertThat(jdbc.updateCalls).allSatisfy(call ->
|
||||
assertThat(call.arguments())
|
||||
.as("JDBC argument count for %s", call.sql().strip())
|
||||
.hasSize(questionMarks(call.sql()))
|
||||
);
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private DependencyFreeCompletionService service(ObjectImportJdbcTemplate jdbc) {
|
||||
return new DependencyFreeCompletionService(
|
||||
jdbc,
|
||||
(KafkaTemplate<String, Object>) mock(KafkaTemplate.class),
|
||||
mock(CapabilityCompletionService.class)
|
||||
);
|
||||
}
|
||||
|
||||
private static int questionMarks(String sql) {
|
||||
return (int) sql.chars().filter(character -> character == '?').count();
|
||||
}
|
||||
|
||||
private record UpdateCall(String sql, Object[] arguments) {
|
||||
}
|
||||
|
||||
private static final class ObjectImportJdbcTemplate extends JdbcTemplate {
|
||||
private final List<UpdateCall> updateCalls = new ArrayList<>();
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> queryForList(String sql, Object... args) {
|
||||
if (sql.contains("invalid_count") && sql.contains("from inspection_object_import_jobs")) {
|
||||
return List.of(new HashMap<>(Map.of(
|
||||
"id", "import-job-123456789012",
|
||||
"status", "READY",
|
||||
"invalid_count", 0,
|
||||
"created_by", "importer-1"
|
||||
)));
|
||||
}
|
||||
if (sql.contains("select id,status from inspection_object_import_jobs")) {
|
||||
return List.of(new HashMap<>(Map.of(
|
||||
"id", "import-job-1",
|
||||
"status", "COMMITTED"
|
||||
)));
|
||||
}
|
||||
if (sql.contains("canonical_payload") && sql.contains("from inspection_object_import_rows")) {
|
||||
return List.of(new HashMap<>(Map.of(
|
||||
"id", "import-row-1",
|
||||
"canonical_payload", "{\"object_type\":\"BRIDGE\",\"name\":\"一号桥\",\"line_id\":\"line-1\",\"mileage_start\":\"K1\",\"mileage_end\":\"K2\",\"attributes\":{}}"
|
||||
)));
|
||||
}
|
||||
throw new AssertionError("Unexpected query: " + sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
@SuppressWarnings("unchecked")
|
||||
public <T> List<T> queryForList(String sql, Class<T> elementType, Object... args) {
|
||||
if (sql.contains("select imported_object_id")) {
|
||||
return (List<T>) List.of("object-1");
|
||||
}
|
||||
throw new AssertionError("Unexpected typed query: " + sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public <T> T queryForObject(String sql, Class<T> requiredType, Object... args) {
|
||||
if (sql.contains("from inspection_task_objects")) {
|
||||
return requiredType.cast(0);
|
||||
}
|
||||
throw new AssertionError("Unexpected scalar query: " + sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(String sql, Object... args) {
|
||||
updateCalls.add(new UpdateCall(sql, args.clone()));
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
}
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
package com.ai.trackwalker.foundation.service;
|
||||
|
||||
import com.ai.trackwalker.capability.service.CapabilityCompletionService;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
|
||||
import java.sql.Timestamp;
|
||||
import java.time.Duration;
|
||||
import java.time.Instant;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.mockito.Mockito.mock;
|
||||
|
||||
class DependencyFreeCompletionServicePlanPreviewTest {
|
||||
|
||||
@Test
|
||||
void manualPlanReturnsOnlyItsSingleExecutionWindow() {
|
||||
Instant scheduledAt = Instant.parse("2026-08-02T01:30:00Z");
|
||||
DependencyFreeCompletionService service = service(plan(
|
||||
"MANUAL",
|
||||
"{\"timing\":\"SCHEDULED\"}",
|
||||
scheduledAt
|
||||
));
|
||||
|
||||
List<Map<String, Object>> runs = service.previewPlanRuns("plan-manual", 6);
|
||||
|
||||
assertThat(runs).hasSize(1);
|
||||
assertThat(runs.get(0))
|
||||
.containsEntry("sequence", 1)
|
||||
.containsEntry("scheduled_window_start", scheduledAt.toString())
|
||||
.containsEntry("scheduled_window_end", scheduledAt.plus(Duration.ofHours(1)).toString())
|
||||
.containsEntry("source", "MANUAL_PREVIEW");
|
||||
assertThat(String.valueOf(runs.get(0).get("generation_key"))).hasSize(64);
|
||||
}
|
||||
|
||||
@Test
|
||||
void immediateManualPlanUsesTheCurrentSingleWindow() {
|
||||
Instant before = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.HOURS);
|
||||
DependencyFreeCompletionService service = service(plan(
|
||||
"MANUAL",
|
||||
"{\"timing\":\"NOW\"}",
|
||||
null
|
||||
));
|
||||
|
||||
List<Map<String, Object>> runs = service.previewPlanRuns("plan-manual-now", 20);
|
||||
Instant after = Instant.now().truncatedTo(java.time.temporal.ChronoUnit.HOURS);
|
||||
|
||||
assertThat(runs).hasSize(1);
|
||||
assertThat(Instant.parse(String.valueOf(runs.get(0).get("scheduled_window_start"))))
|
||||
.isIn(before, after);
|
||||
assertThat(runs.get(0)).containsEntry("source", "MANUAL_PREVIEW");
|
||||
}
|
||||
|
||||
@Test
|
||||
void periodicPlanStillRespectsRequestedPreviewCount() {
|
||||
DependencyFreeCompletionService service = service(plan(
|
||||
"PERIODIC",
|
||||
"{\"frequency\":\"DAILY\",\"interval_days\":1,\"execution_time\":\"09:00\"}",
|
||||
Instant.parse("2026-08-02T01:00:00Z")
|
||||
));
|
||||
|
||||
List<Map<String, Object>> runs = service.previewPlanRuns("plan-periodic", 3);
|
||||
|
||||
assertThat(runs).hasSize(3);
|
||||
assertThat(runs).allSatisfy(run -> assertThat(run).containsEntry("source", "SCHEDULE_PREVIEW"));
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private DependencyFreeCompletionService service(Map<String, Object> plan) {
|
||||
return new DependencyFreeCompletionService(
|
||||
new PlanJdbcTemplate(plan),
|
||||
(KafkaTemplate<String, Object>) mock(KafkaTemplate.class),
|
||||
mock(CapabilityCompletionService.class)
|
||||
);
|
||||
}
|
||||
|
||||
private Map<String, Object> plan(String triggerType, String scheduleConfig, Instant nextRunAt) {
|
||||
Map<String, Object> plan = new HashMap<>();
|
||||
plan.put("id", "plan-1");
|
||||
plan.put("trigger_type", triggerType);
|
||||
plan.put("schedule_rule", "PERIODIC".equals(triggerType) ? "DAILY" : null);
|
||||
plan.put("schedule_config", scheduleConfig);
|
||||
plan.put("schedule_timezone", "Asia/Shanghai");
|
||||
plan.put("next_run_at", nextRunAt == null ? null : Timestamp.from(nextRunAt));
|
||||
plan.put("effective_to", null);
|
||||
return plan;
|
||||
}
|
||||
|
||||
private static final class PlanJdbcTemplate extends JdbcTemplate {
|
||||
private final Map<String, Object> plan;
|
||||
|
||||
private PlanJdbcTemplate(Map<String, Object> plan) {
|
||||
this.plan = plan;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> queryForList(String sql, Object... args) {
|
||||
if (sql.contains("from inspection_plans")) {
|
||||
return List.of(new HashMap<>(plan));
|
||||
}
|
||||
throw new AssertionError("Unexpected query: " + sql);
|
||||
}
|
||||
}
|
||||
}
|
||||
+55
@@ -0,0 +1,55 @@
|
||||
package com.ai.trackwalker.operations;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
|
||||
class OperationsHealthServiceTest {
|
||||
|
||||
@Test
|
||||
void keepsProbeResultsIndependentAndMarksAffectedIntegrations() {
|
||||
OperationsHealthService service = new OperationsHealthService(List.of(
|
||||
OperationsHealthService.definition(
|
||||
"platform", "业务平台", "/actuator/health", "服务内自检",
|
||||
() -> OperationsHealthService.up("接口已响应")
|
||||
),
|
||||
OperationsHealthService.definition(
|
||||
"kafka", "Kafka", "kafka:9092", "集群元数据",
|
||||
() -> {
|
||||
throw new IllegalStateException("broker unavailable");
|
||||
}
|
||||
),
|
||||
OperationsHealthService.definition(
|
||||
"uav-access", "无人机接入服务", "uav-access:8091", "HTTP /health",
|
||||
() -> OperationsHealthService.down("HTTP 503")
|
||||
)
|
||||
));
|
||||
|
||||
try {
|
||||
Map<String, Object> snapshot = service.snapshot();
|
||||
|
||||
assertThat(snapshot).containsEntry("status", "DEGRADED");
|
||||
assertThat(rows(snapshot, "services"))
|
||||
.extracting(row -> row.get("code") + ":" + row.get("status"))
|
||||
.containsExactly("platform:UP", "kafka:DOWN", "uav-access:DOWN");
|
||||
assertThat(rows(snapshot, "integrations"))
|
||||
.filteredOn(row -> List.of("resource-ingest", "event-bus", "uav-task").contains(row.get("code")))
|
||||
.extracting(row -> row.get("code") + ":" + row.get("status_text"))
|
||||
.containsExactlyInAnyOrder(
|
||||
"resource-ingest:接口已提供",
|
||||
"event-bus:依赖异常",
|
||||
"uav-task:依赖异常"
|
||||
);
|
||||
} finally {
|
||||
service.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private List<Map<String, Object>> rows(Map<String, Object> snapshot, String key) {
|
||||
return (List<Map<String, Object>>) snapshot.get(key);
|
||||
}
|
||||
}
|
||||
+169
@@ -0,0 +1,169 @@
|
||||
package com.ai.trackwalker.service;
|
||||
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.springframework.jdbc.core.JdbcTemplate;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.transaction.PlatformTransactionManager;
|
||||
import org.springframework.transaction.TransactionDefinition;
|
||||
import org.springframework.transaction.TransactionStatus;
|
||||
import org.springframework.transaction.support.SimpleTransactionStatus;
|
||||
import org.springframework.web.server.ResponseStatusException;
|
||||
|
||||
import java.util.ArrayList;
|
||||
import java.util.HashMap;
|
||||
import java.util.List;
|
||||
import java.util.Map;
|
||||
|
||||
import static org.assertj.core.api.Assertions.assertThat;
|
||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||
import static org.mockito.ArgumentMatchers.anyList;
|
||||
import static org.mockito.ArgumentMatchers.anyMap;
|
||||
import static org.mockito.Mockito.mock;
|
||||
import static org.mockito.Mockito.never;
|
||||
import static org.mockito.Mockito.times;
|
||||
import static org.mockito.Mockito.verify;
|
||||
import static org.mockito.Mockito.when;
|
||||
|
||||
class PlatformServiceAnalysisTest {
|
||||
|
||||
@Test
|
||||
void completedJobIsIdempotentAndDoesNotCreateDuplicateResults() {
|
||||
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate("queued");
|
||||
AiClient aiClient = mock(AiClient.class);
|
||||
when(aiClient.analyze(anyMap(), anyList())).thenReturn(List.of(new HashMap<>(Map.of(
|
||||
"scene", "塔吊",
|
||||
"category", "塔吊",
|
||||
"confidence", 0.91,
|
||||
"measurements", Map.of("distance_to_track_m", 83.2)
|
||||
))));
|
||||
PlatformService service = service(jdbc, aiClient);
|
||||
|
||||
Map<String, Object> first = service.runAnalysis("job-1");
|
||||
Map<String, Object> second = service.runAnalysis("job-1");
|
||||
|
||||
assertThat(first.get("status")).isEqualTo("completed");
|
||||
assertThat(second).containsEntry("status", "completed").containsEntry("idempotent", true);
|
||||
verify(aiClient, times(1)).analyze(anyMap(), anyList());
|
||||
assertThat(jdbc.updates.stream().filter(sql -> sql.contains("insert into ai_results"))).hasSize(1);
|
||||
assertThat(jdbc.updates.stream().filter(sql -> sql.contains("insert into alarms"))).hasSize(1);
|
||||
assertThat(jdbc.updates.stream().filter(sql -> sql.contains("insert into work_orders"))).hasSize(1);
|
||||
}
|
||||
|
||||
@Test
|
||||
void runningJobReturnsCurrentStateWithoutStartingAnotherExecution() {
|
||||
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate("running");
|
||||
AiClient aiClient = mock(AiClient.class);
|
||||
PlatformService service = service(jdbc, aiClient);
|
||||
|
||||
Map<String, Object> result = service.runAnalysis("job-1");
|
||||
|
||||
assertThat(result).containsEntry("status", "running").containsEntry("idempotent", true);
|
||||
verify(aiClient, never()).analyze(anyMap(), anyList());
|
||||
assertThat(jdbc.updates).isEmpty();
|
||||
}
|
||||
|
||||
@Test
|
||||
void failedRetryPersistsFailedStatusAfterInferenceError() {
|
||||
RecordingJdbcTemplate jdbc = new RecordingJdbcTemplate("failed");
|
||||
AiClient aiClient = mock(AiClient.class);
|
||||
when(aiClient.analyze(anyMap(), anyList())).thenThrow(new IllegalStateException("inference unavailable"));
|
||||
PlatformService service = service(jdbc, aiClient);
|
||||
|
||||
assertThatThrownBy(() -> service.runAnalysis("job-1"))
|
||||
.isInstanceOf(ResponseStatusException.class)
|
||||
.hasMessageContaining("分析任务执行失败")
|
||||
.hasMessageContaining("inference unavailable");
|
||||
|
||||
assertThat(jdbc.updates).anyMatch(sql -> sql.contains("set status='running'"));
|
||||
assertThat(jdbc.updates).anyMatch(sql -> sql.contains("set status='failed'"));
|
||||
assertThat(jdbc.jobStatus).isEqualTo("failed");
|
||||
assertThat(jdbc.summary).contains("inference unavailable");
|
||||
}
|
||||
|
||||
@SuppressWarnings("unchecked")
|
||||
private PlatformService service(RecordingJdbcTemplate jdbc, AiClient aiClient) {
|
||||
return new PlatformService(
|
||||
jdbc,
|
||||
aiClient,
|
||||
new RuleEngine(),
|
||||
(KafkaTemplate<String, Object>) mock(KafkaTemplate.class),
|
||||
new NoOpTransactionManager()
|
||||
);
|
||||
}
|
||||
|
||||
private static final class RecordingJdbcTemplate extends JdbcTemplate {
|
||||
private final List<String> updates = new ArrayList<>();
|
||||
private String jobStatus;
|
||||
private String summary = "{}";
|
||||
|
||||
private RecordingJdbcTemplate(String jobStatus) {
|
||||
this.jobStatus = jobStatus;
|
||||
}
|
||||
|
||||
@Override
|
||||
public List<Map<String, Object>> queryForList(String sql, Object... args) {
|
||||
if (sql.contains("from analysis_jobs")) {
|
||||
Map<String, Object> job = new HashMap<>();
|
||||
job.put("id", "job-1");
|
||||
job.put("task_id", "task-1");
|
||||
job.put("resource_ids_text", "[\"resource-1\"]");
|
||||
job.put("scene_set_text", "[\"塔吊\"]");
|
||||
job.put("status", jobStatus);
|
||||
job.put("summary_text", summary);
|
||||
return List.of(job);
|
||||
}
|
||||
return List.of();
|
||||
}
|
||||
|
||||
@Override
|
||||
public Map<String, Object> queryForMap(String sql, Object... args) {
|
||||
if (sql.contains("from inspection_tasks")) {
|
||||
return new HashMap<>(Map.of(
|
||||
"id", "task-1",
|
||||
"line_id", "line-1",
|
||||
"scene_set_text", "[\"塔吊\"]"
|
||||
));
|
||||
}
|
||||
if (sql.contains("from inspection_resources")) {
|
||||
return new HashMap<>(Map.of(
|
||||
"id", "resource-1",
|
||||
"task_id", "task-1",
|
||||
"storage_url", "s3://bucket/resource-1.jpg",
|
||||
"metadata", "{}"
|
||||
));
|
||||
}
|
||||
throw new AssertionError("Unexpected query: " + sql);
|
||||
}
|
||||
|
||||
@Override
|
||||
public int update(String sql, Object... args) {
|
||||
updates.add(sql);
|
||||
if (sql.contains("update analysis_jobs") && sql.contains("set status='running'")) {
|
||||
jobStatus = "running";
|
||||
summary = "{}";
|
||||
} else if (sql.contains("update analysis_jobs") && sql.contains("set status='completed'")) {
|
||||
jobStatus = "completed";
|
||||
summary = String.valueOf(args[0]);
|
||||
} else if (sql.contains("update analysis_jobs") && sql.contains("set status='failed'")) {
|
||||
jobStatus = "failed";
|
||||
summary = String.valueOf(args[0]);
|
||||
}
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
private static final class NoOpTransactionManager implements PlatformTransactionManager {
|
||||
@Override
|
||||
public TransactionStatus getTransaction(TransactionDefinition definition) {
|
||||
return new SimpleTransactionStatus();
|
||||
}
|
||||
|
||||
@Override
|
||||
public void commit(TransactionStatus status) {
|
||||
}
|
||||
|
||||
@Override
|
||||
public void rollback(TransactionStatus status) {
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$baseUrl = if ($env:RAIL_WEB_BASE_URL) { $env:RAIL_WEB_BASE_URL.TrimEnd("/") } else { "http://localhost:8088" }
|
||||
$baseUrl = if ($env:RAIL_WEB_BASE_URL) { $env:RAIL_WEB_BASE_URL.TrimEnd("/") } else { "http://127.0.0.1:8088" }
|
||||
$apiUrl = "$baseUrl/api/v1"
|
||||
$runKey = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmssfff")
|
||||
$tempPng = Join-Path ([IO.Path]::GetTempPath()) "rail-capability-$runKey.png"
|
||||
@@ -77,6 +77,19 @@ function Invoke-WorkOrderAction {
|
||||
}
|
||||
}
|
||||
|
||||
function Invoke-WorkflowAction {
|
||||
param([string]$BusinessType, [string]$BusinessId, [string]$Action, [int]$Sequence)
|
||||
$isApproval = $Action -in @("APPROVE", "REJECT")
|
||||
return Invoke-RailApi -Method POST -Path "/workflows/$BusinessType/$BusinessId/actions" -Body @{
|
||||
action = $Action
|
||||
operator_id = if ($isApproval) { "user-approver" } else { "user-dispatcher" }
|
||||
operator_name = if ($isApproval) { "System Approver" } else { "System Dispatcher" }
|
||||
opinion = "capability completion regression"
|
||||
attachments = @()
|
||||
idempotency_key = "$runKey-workflow-$BusinessType-$BusinessId-$Sequence"
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
Write-Host "Checking capability registry and spatial objects..."
|
||||
$completion = Invoke-RailApi -Method GET -Path "/capabilities/completion"
|
||||
@@ -101,6 +114,9 @@ try {
|
||||
}
|
||||
$validation = Invoke-RailApi -Method POST -Path "/route-versions/$($route.route_version_id)/validate"
|
||||
if (-not $validation.valid) { throw "Route validation failed" }
|
||||
Invoke-WorkflowAction -BusinessType "ROUTE_VERSION" -BusinessId $route.route_version_id -Action "SUBMIT" -Sequence 1 | Out-Null
|
||||
$routeApproval = Invoke-WorkflowAction -BusinessType "ROUTE_VERSION" -BusinessId $route.route_version_id -Action "APPROVE" -Sequence 2
|
||||
if ($routeApproval.state -ne "APPROVED") { throw "Route approval failed" }
|
||||
$published = Invoke-RailApi -Method POST -Path "/route-versions/$($route.route_version_id)/publish?approvedBy=system-tester"
|
||||
if ($published.status -ne "PUBLISHED") { throw "Route publication failed" }
|
||||
|
||||
@@ -108,7 +124,8 @@ try {
|
||||
$plan = Invoke-RailApi -Method POST -Path "/inspection/plans" -Body @{
|
||||
name = "Capability regression plan $runKey"
|
||||
plan_type = "SPECIAL"
|
||||
schedule_rule = "MANUAL"
|
||||
trigger_type = "MANUAL"
|
||||
schedule_config = @{ timing = "NOW" }
|
||||
object_ids = @($object.object_id)
|
||||
scene_set = @("FOREIGN_OBJECT", "FENCE_DAMAGE")
|
||||
priority = "high"
|
||||
@@ -120,6 +137,9 @@ try {
|
||||
$generated = Invoke-RailApi -Method POST -Path "/inspection/plans/$($plan.plan_id)/generate-tasks"
|
||||
$taskId = @($generated.task_ids)[0]
|
||||
if (-not $taskId) { throw "Plan did not generate an inspection task" }
|
||||
Invoke-WorkflowAction -BusinessType "TASK" -BusinessId $taskId -Action "SUBMIT" -Sequence 3 | Out-Null
|
||||
$taskApproval = Invoke-WorkflowAction -BusinessType "TASK" -BusinessId $taskId -Action "APPROVE" -Sequence 4
|
||||
if ($taskApproval.state -ne "APPROVED") { throw "Task approval failed" }
|
||||
|
||||
Write-Host "Checking simulator connection and executing a flight mission..."
|
||||
$connections = Invoke-RailApi -Method GET -Path "/uav/connections"
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
$ErrorActionPreference = "Stop"
|
||||
|
||||
$baseUrl = if ($env:RAIL_WEB_BASE_URL) { $env:RAIL_WEB_BASE_URL.TrimEnd("/") } else { "http://localhost:8088" }
|
||||
$baseUrl = if ($env:RAIL_WEB_BASE_URL) { $env:RAIL_WEB_BASE_URL.TrimEnd("/") } else { "http://127.0.0.1:8088" }
|
||||
$apiUrl = "$baseUrl/api/v1"
|
||||
|
||||
Write-Host "Checking navigation routes..."
|
||||
@@ -87,7 +87,35 @@ $missionBody = @{
|
||||
$mission = Invoke-RestMethod -Method Post -Uri "$apiUrl/uav/callbacks/mission-status" -ContentType "application/json" -Body $missionBody -TimeoutSec 30
|
||||
if ($mission.data.status -ne "flying") { throw "Mission status update failed" }
|
||||
|
||||
$cancel = Invoke-RestMethod -Method Post -Uri "$apiUrl/inspection/tasks/$taskId/cancel" -ContentType "application/json" -Body "{}" -TimeoutSec 30
|
||||
$completedMissionBody = @{
|
||||
task_id = $taskId
|
||||
vendor = "DJI"
|
||||
vendor_mission_id = "mission-navigation-test"
|
||||
status = "completed"
|
||||
uav_id = "UAV-TEST"
|
||||
timestamp = (Get-Date).ToUniversalTime().ToString("o")
|
||||
position = @{ longitude = 116.1; latitude = 39.1 }
|
||||
} | ConvertTo-Json -Depth 4
|
||||
$completedMission = Invoke-RestMethod -Method Post -Uri "$apiUrl/uav/callbacks/mission-status" -ContentType "application/json" -Body $completedMissionBody -TimeoutSec 30
|
||||
if ($completedMission.data.status -ne "completed") { throw "Mission completion update failed" }
|
||||
|
||||
# Cancellation is only valid before a mission has been created. Use a separate
|
||||
# pending task so this test covers both lifecycle paths without violating the
|
||||
# production safety guard.
|
||||
$cancelTaskBody = @{
|
||||
external_task_id = "NAVIGATION-CANCEL-TEST"
|
||||
line_id = "line-test"
|
||||
mileage_start = "K12+000"
|
||||
mileage_end = "K13+000"
|
||||
route_id = "route-navigation-cancel-test"
|
||||
scene_set = @("浜哄憳鍏ヤ镜")
|
||||
priority = "normal"
|
||||
planned_start_time = (Get-Date).ToUniversalTime().AddMinutes(15).ToString("o")
|
||||
} | ConvertTo-Json
|
||||
$cancelTask = Invoke-RestMethod -Method Post -Uri "$apiUrl/inspection/tasks" -ContentType "application/json" -Body $cancelTaskBody -TimeoutSec 30
|
||||
$cancelTaskId = $cancelTask.data.task_id
|
||||
if (-not $cancelTaskId) { throw "Cancellation task creation did not return task_id" }
|
||||
$cancel = Invoke-RestMethod -Method Post -Uri "$apiUrl/inspection/tasks/$cancelTaskId/cancel" -ContentType "application/json" -Body "{}" -TimeoutSec 30
|
||||
if ($cancel.data.status -ne "cancelled") { throw "Task cancellation failed" }
|
||||
|
||||
Write-Host "Checking alarm decision operation..."
|
||||
@@ -96,15 +124,21 @@ $alarmId = @($alarmsResponse.data.alarms | Where-Object { $_.status -ne "closed"
|
||||
if ($alarmId) {
|
||||
$confirmBody = @{ action = "confirm"; reason = "页面操作回归"; operator = "测试人员" } | ConvertTo-Json
|
||||
$confirmed = Invoke-RestMethod -Method Post -Uri "$apiUrl/alarms/$alarmId/decision" -ContentType "application/json" -Body $confirmBody -TimeoutSec 30
|
||||
if ($confirmed.data.status -ne "confirmed") { throw "Alarm confirmation failed" }
|
||||
$validConfirmationStates = @("confirmed", "dispatched", "processing", "closed")
|
||||
if ($validConfirmationStates -notcontains $confirmed.data.status) {
|
||||
throw "Alarm confirmation failed: unexpected status $($confirmed.data.status)"
|
||||
}
|
||||
}
|
||||
|
||||
$result = [ordered]@{
|
||||
route_count = $routes.Count
|
||||
endpoint_count = $endpointChecks.Count
|
||||
created_task_id = $taskId
|
||||
task_final_status = $cancel.data.status
|
||||
task_final_status = $completedMission.data.status
|
||||
cancelled_task_id = $cancelTaskId
|
||||
cancelled_task_status = $cancel.data.status
|
||||
alarm_decision_checked = [bool]$alarmId
|
||||
alarm_decision_status = if ($alarmId) { $confirmed.data.status } else { $null }
|
||||
endpoints = $summary
|
||||
}
|
||||
$result | ConvertTo-Json -Depth 5
|
||||
|
||||
@@ -20,8 +20,8 @@ if (-not $spatial.open3d_available) {
|
||||
$models = @($vision.models) + @($spatial.models)
|
||||
$groups = @($models | Select-Object -ExpandProperty model_group -Unique)
|
||||
$expected = @("vision-detector", "vision-segmenter", "change-detector", "pointcloud-analyzer", "thermal-analyzer")
|
||||
if ($models.Count -ne 5) {
|
||||
throw "CPU runtime must expose exactly five model groups, received $($models.Count)."
|
||||
if ($groups.Count -ne 5) {
|
||||
throw "CPU runtime must expose exactly five model groups, received $($groups.Count)."
|
||||
}
|
||||
foreach ($group in $expected) {
|
||||
if ($group -notin $groups) {
|
||||
|
||||
@@ -7,9 +7,11 @@ spring:
|
||||
jackson:
|
||||
property-naming-strategy: SNAKE_CASE
|
||||
datasource:
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/rail_inspection}
|
||||
url: ${SPRING_DATASOURCE_URL:jdbc:postgresql://localhost:5432/rail_inspection?currentSchema=uav_access}
|
||||
username: ${SPRING_DATASOURCE_USERNAME:rail}
|
||||
password: ${SPRING_DATASOURCE_PASSWORD:rail}
|
||||
hikari:
|
||||
schema: ${SPRING_DATASOURCE_SCHEMA:uav_access}
|
||||
flyway:
|
||||
enabled: true
|
||||
create-schemas: true
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
outputs/*
|
||||
!outputs/.gitkeep
|
||||
@@ -0,0 +1 @@
|
||||
|
||||
@@ -0,0 +1,778 @@
|
||||
# 视频实时目标检测与图像分割可视化演示设计文档
|
||||
|
||||
## 1. 文档信息
|
||||
|
||||
| 项目 | 内容 |
|
||||
| --- | --- |
|
||||
| 文档版本 | V0.2 |
|
||||
| 编制日期 | 2026-08-01 |
|
||||
| 目标目录 | `visualization-demo/` |
|
||||
| 目标页面 | 上传本地视频并在播放过程中按需启用目标检测、图像分割实时叠加,并可生成完整结果视频保存到本地 |
|
||||
| 适用视频 | 无人机航拍视频、手机拍摄视频、铁路巡检样例视频 |
|
||||
| 默认部署形态 | 前端 Vue 3 + Vite,后端 FastAPI + OpenCV + ONNX Runtime GPU |
|
||||
|
||||
## 2. 目标与边界
|
||||
|
||||
### 2.1 建设目标
|
||||
|
||||
1. 在项目根目录新增一个独立的可视化演示目录,用于沉淀视频 AI 演示方案、后续页面和服务适配文件。
|
||||
2. 页面支持上传本地视频,使用浏览器原生视频播放器播放,不强制把完整视频上传到后端。
|
||||
3. 页面提供目标检测开关和图像分割开关,用户打开后对当前播放帧进行实时推理。
|
||||
4. 推理结果以叠加层方式显示在视频画面上:目标检测显示框、类别、置信度;图像分割显示半透明掩膜或轮廓。
|
||||
5. 运行时优先使用本机 RTX 3090 的 GPU 推理能力;模型制品缺失时必须给出明确的降级状态,而不是伪装成真实推理。
|
||||
6. 页面支持生成完整检测/分割结果视频:把模型检测框、类别、置信度和分割掩膜渲染进整段视频,保存到本地输出目录。
|
||||
7. 完整结果视频同时保存结构化结果 JSON、运行参数、模型版本和日志,便于后续复核、演示和样本回流。
|
||||
|
||||
### 2.2 不在首版范围内
|
||||
|
||||
1. 不自动对所有上传视频做全量分析;只有用户点击“生成结果视频”时才上传完整视频并启动离线任务。
|
||||
2. 不在 Git 仓库中提交大模型权重、视频样例和大体积推理制品。
|
||||
3. 不在首版引入 SAM 一类提示式大模型作为默认分割方案。它适合交互式精分割,但不适合首版实时视频开关。
|
||||
4. 不把演示结果直接标记为生产告警结果。演示页面只展示当前模型能力和性能状态。
|
||||
|
||||
## 3. 当前项目依据
|
||||
|
||||
### 3.1 已发现的工程基础
|
||||
|
||||
| 模块 | 当前状态 | 对本设计的影响 |
|
||||
| --- | --- | --- |
|
||||
| `frontend/` | Vue 3 + Vite + Element Plus,已有路由、侧边栏和 API 服务封装 | 可新增一个演示视图并接入现有导航 |
|
||||
| `frontend/vite.config.ts` | `/api` 代理到 `http://localhost:8080` | 首版可继续走统一后端代理,也可临时直连 `vision-inference` |
|
||||
| `ai-services/vision-inference/` | FastAPI,已有 `/api/v1/inference/detect`、模型加载、运行时状态接口 | 可复用现有模型注册、ONNX Runtime、结果解析能力 |
|
||||
| `ai-services/vision-inference/requirements-server.txt` | 包含 `onnxruntime-gpu`、OpenCV、FastAPI | 适合本机 RTX 3090 推理服务 |
|
||||
| `infra/model-registry/cpu-models.json` | 已登记 `vision-detector`、`vision-segmenter` CPU 模型位 | 可作为 CPU 降级和开发默认配置 |
|
||||
| `infra/model-registry/server-models.json` | 已登记 GPU 侧 `vision-detector`、`vision-segmenter`、`thermal-analyzer` 等模型位 | 可作为 GPU 推理默认配置 |
|
||||
| `runtime/models/` | 已有模型目录约定,说明生产铁路权重不会自动下载 | 后续模型制品放这里或其子目录,不提交 Git |
|
||||
|
||||
### 3.2 本机硬件结论
|
||||
|
||||
已通过 `nvidia-smi` 确认本机 GPU:
|
||||
|
||||
```text
|
||||
NVIDIA GeForce RTX 3090, 24576 MiB, Driver 566.36
|
||||
```
|
||||
|
||||
因此首选方案为 `ONNX Runtime GPU + CUDAExecutionProvider`。RTX 3090 的 24GB 显存足够支撑轻量检测模型和实时语义/实例分割模型在演示页面中并行切换,但仍需要对输入分辨率、推理帧率和并发请求做节流。
|
||||
|
||||
## 4. 用户体验设计
|
||||
|
||||
### 4.1 首屏布局
|
||||
|
||||
页面首屏即为可操作演示台,不做营销式落地页。
|
||||
|
||||
```text
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ 顶部:页面标题、运行状态、GPU/CPU Provider、模型加载状态 │
|
||||
├──────────────────────────────────────────────┬───────────────┤
|
||||
│ │ 上传视频 │
|
||||
│ 视频播放区 │ 检测开关 │
|
||||
│ - video 元素 │ 分割开关 │
|
||||
│ - detection canvas │ 阈值/帧率 │
|
||||
│ - segmentation canvas │ 模型状态 │
|
||||
│ │ 延迟/FPS │
|
||||
│ │ 生成结果视频 │
|
||||
│ │ 本地输出路径 │
|
||||
├──────────────────────────────────────────────┴───────────────┤
|
||||
│ 底部:当前帧结果列表、类别筛选、时间轴命中点、错误/降级提示 │
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 4.2 核心交互
|
||||
|
||||
1. 用户点击上传区域选择视频文件。
|
||||
2. 前端用 `URL.createObjectURL(file)` 生成本地播放地址,视频原文件不默认上传。
|
||||
3. 用户播放视频。
|
||||
4. 用户打开目标检测开关后,前端按设定 FPS 从视频抽帧,发送当前帧到后端检测接口。
|
||||
5. 用户打开图像分割开关后,同一抽帧流程增加分割任务。
|
||||
6. 后端返回归一化坐标结果,前端根据当前视频显示尺寸绘制叠加层。
|
||||
7. 如果视频暂停,默认暂停抽帧推理;保留最后一帧叠加结果。
|
||||
8. 如果拖动进度条,清空旧结果并从新时间点继续推理。
|
||||
9. 用户点击“生成结果视频”后,前端上传完整视频和当前模型参数,后端启动离线任务。
|
||||
10. 后端逐帧或按配置抽帧推理、插值/复用结果并把检测框与分割掩膜绘制进输出视频。
|
||||
11. 任务完成后页面展示本地保存路径、视频预览地址、结果 JSON 和处理耗时。
|
||||
|
||||
### 4.3 页面控件
|
||||
|
||||
| 控件 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| 上传视频 | 文件选择按钮 | 支持 `mp4`、`webm`、浏览器可解码的 `mov` |
|
||||
| 目标检测 | 开关 | 控制 `vision-detector` 或通用检测模型 |
|
||||
| 图像分割 | 开关 | 控制 `vision-segmenter` 或通用实例分割模型 |
|
||||
| 检测置信度 | 滑块 | 默认 0.45,范围 0.05 到 0.95 |
|
||||
| 分割阈值 | 滑块 | 默认 0.5,范围 0.1 到 0.9 |
|
||||
| 推理帧率 | 步进器或滑块 | GPU 默认检测 8 FPS、分割 3 FPS;CPU 自动降低 |
|
||||
| 最大推理宽度 | 下拉 | 默认 960,选项 640、960、1280 |
|
||||
| 结果显示 | 勾选项 | 类别标签、置信度、轮廓、掩膜透明度 |
|
||||
| 模型预热 | 按钮 | 主动加载模型,减少第一次打开开关的延迟 |
|
||||
| 生成结果视频 | 按钮 | 上传完整视频,后台生成带检测/分割叠加的本地结果视频 |
|
||||
| 输出目录 | 只读文本/打开按钮 | 显示 `visualization-demo/outputs/video-runs/<run_id>/` |
|
||||
| 任务进度 | 进度条 | 展示已处理帧数、总帧数、预计剩余时间 |
|
||||
| 结果下载 | 按钮 | 下载生成后的 `annotated.mp4`、`results.json` 和 `run-metadata.json` |
|
||||
|
||||
## 5. 技术架构
|
||||
|
||||
### 5.1 总体架构
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["浏览器视频文件"] --> B["HTMLVideoElement 播放"]
|
||||
B --> C["Canvas 抽帧"]
|
||||
C --> D["FrameScheduler 节流与丢帧"]
|
||||
D --> E["POST /api/v1/video-demo/infer-frame"]
|
||||
E --> F["FastAPI 视频演示接口"]
|
||||
F --> G["OpenCV 解码单帧"]
|
||||
G --> H["ONNX Runtime GPU"]
|
||||
H --> I1["vision-detector"]
|
||||
H --> I2["vision-segmenter"]
|
||||
I1 --> J["统一归一化结果"]
|
||||
I2 --> J
|
||||
J --> K["前端 OverlayRenderer"]
|
||||
K --> L["检测框/分割掩膜叠加"]
|
||||
```
|
||||
|
||||
### 5.1.1 完整结果视频生成链路
|
||||
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["用户点击生成结果视频"] --> B["上传完整视频与模型参数"]
|
||||
B --> C["POST /api/v1/video-demo/export-jobs"]
|
||||
C --> D["本地任务目录 video-runs/<run_id>"]
|
||||
D --> E["OpenCV VideoCapture 逐帧读取"]
|
||||
E --> F["按配置缩放推理帧"]
|
||||
F --> G["检测/分割 ONNX 推理"]
|
||||
G --> H["OverlayComposer 绘制框和掩膜"]
|
||||
H --> I["OpenCV VideoWriter 输出 annotated.mp4"]
|
||||
G --> J["保存 per-frame results.jsonl"]
|
||||
I --> K["GET /api/v1/video-demo/export-jobs/<run_id>"]
|
||||
J --> K
|
||||
K --> L["页面展示保存路径和下载入口"]
|
||||
```
|
||||
|
||||
### 5.2 前端模块
|
||||
|
||||
建议新增:
|
||||
|
||||
```text
|
||||
frontend/src/views/visualization-demo/VideoAiDemoView.vue
|
||||
frontend/src/components/video-ai/VideoStage.vue
|
||||
frontend/src/components/video-ai/VideoAiControlPanel.vue
|
||||
frontend/src/components/video-ai/ResultTimeline.vue
|
||||
frontend/src/composables/useVideoFrameInference.ts
|
||||
frontend/src/services/videoDemoApi.ts
|
||||
```
|
||||
|
||||
职责划分:
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `VideoAiDemoView.vue` | 页面容器,维护视频文件、开关、阈值和模型状态 |
|
||||
| `VideoStage.vue` | 视频播放、叠加层尺寸同步、拖动/暂停事件 |
|
||||
| `VideoAiControlPanel.vue` | 上传、开关、阈值、FPS、模型预热 |
|
||||
| `ResultTimeline.vue` | 展示当前视频时间附近的检测/分割结果 |
|
||||
| `useVideoFrameInference.ts` | 抽帧、请求调度、请求取消、丢帧策略 |
|
||||
| `videoDemoApi.ts` | 封装能力查询、帧推理、模型预热接口 |
|
||||
|
||||
### 5.3 后端模块
|
||||
|
||||
建议在 `ai-services/vision-inference/app/` 新增:
|
||||
|
||||
```text
|
||||
video_demo_routes.py
|
||||
video_demo_schemas.py
|
||||
frame_runtime.py
|
||||
video_export_runtime.py
|
||||
```
|
||||
|
||||
职责划分:
|
||||
|
||||
| 模块 | 职责 |
|
||||
| --- | --- |
|
||||
| `video_demo_routes.py` | 暴露能力查询、模型预热、单帧推理接口 |
|
||||
| `video_demo_schemas.py` | 定义请求参数和响应结构 |
|
||||
| `frame_runtime.py` | 复用现有 ONNX Runtime,对上传帧进行检测/分割 |
|
||||
| `video_export_runtime.py` | 负责完整视频读取、推理、叠加渲染、本地文件写入和进度记录 |
|
||||
|
||||
首版使用 HTTP 单帧接口,避免 WebSocket 复杂度。若后续需要更高帧率,再增加 WebSocket 或本地共享内存方案。
|
||||
|
||||
完整结果视频生成使用后台任务,不阻塞实时预览接口。首版可使用 FastAPI `BackgroundTasks` 或进程内任务队列;后续再接入 Celery、Redis Queue 或平台现有任务调度。
|
||||
|
||||
## 6. 接口设计
|
||||
|
||||
### 6.1 查询运行能力
|
||||
|
||||
```http
|
||||
GET /api/v1/video-demo/capabilities
|
||||
```
|
||||
|
||||
响应示例:
|
||||
|
||||
```json
|
||||
{
|
||||
"runtime": "onnxruntime-gpu",
|
||||
"execution_provider": "CUDAExecutionProvider",
|
||||
"gpu": {
|
||||
"name": "NVIDIA GeForce RTX 3090",
|
||||
"memory_total_mb": 24576
|
||||
},
|
||||
"models": [
|
||||
{
|
||||
"model_group": "vision-detector",
|
||||
"model_version": "v1.0.0",
|
||||
"display_name": "PP-YOLOE-SOD-s",
|
||||
"artifact_installed": true,
|
||||
"loaded": false
|
||||
},
|
||||
{
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "v1.0.0",
|
||||
"display_name": "PP-LiteSeg-STDC1",
|
||||
"artifact_installed": true,
|
||||
"loaded": false
|
||||
}
|
||||
],
|
||||
"recommended": {
|
||||
"max_inference_width": 960,
|
||||
"detection_fps": 8,
|
||||
"segmentation_fps": 3
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### 6.2 模型预热
|
||||
|
||||
```http
|
||||
POST /api/v1/video-demo/warmup
|
||||
Content-Type: application/json
|
||||
```
|
||||
|
||||
请求:
|
||||
|
||||
```json
|
||||
{
|
||||
"models": ["vision-detector", "vision-segmenter"]
|
||||
}
|
||||
```
|
||||
|
||||
### 6.3 单帧推理
|
||||
|
||||
```http
|
||||
POST /api/v1/video-demo/infer-frame
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `frame` | file | 当前视频帧,建议 JPEG |
|
||||
| `session_id` | string | 前端生成的本次视频会话 ID |
|
||||
| `timestamp_ms` | number | 视频当前时间 |
|
||||
| `source_width` | number | 视频原始宽度 |
|
||||
| `source_height` | number | 视频原始高度 |
|
||||
| `detect_enabled` | boolean | 是否执行检测 |
|
||||
| `segment_enabled` | boolean | 是否执行分割 |
|
||||
| `confidence_threshold` | number | 检测置信度阈值 |
|
||||
| `mask_threshold` | number | 分割阈值 |
|
||||
| `max_detections` | number | 最大检测数量 |
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"session_id": "demo-20260801-001",
|
||||
"timestamp_ms": 12345,
|
||||
"frame_id": "demo-20260801-001:12345",
|
||||
"source": {
|
||||
"width": 1920,
|
||||
"height": 1080
|
||||
},
|
||||
"runtime": {
|
||||
"provider": "CUDAExecutionProvider",
|
||||
"total_latency_ms": 42.7,
|
||||
"decode_latency_ms": 3.1,
|
||||
"detection_latency_ms": 18.4,
|
||||
"segmentation_latency_ms": 21.2
|
||||
},
|
||||
"results": {
|
||||
"detections": [
|
||||
{
|
||||
"category": "person",
|
||||
"confidence": 0.91,
|
||||
"bbox": [0.42, 0.18, 0.53, 0.71],
|
||||
"model_group": "vision-detector",
|
||||
"model_version": "v1.0.0"
|
||||
}
|
||||
],
|
||||
"segments": [
|
||||
{
|
||||
"category": "roadbed",
|
||||
"confidence": 1.0,
|
||||
"polygon": [[0.1, 0.7], [0.5, 0.45], [0.92, 0.74], [0.1, 0.7]],
|
||||
"area_ratio": 0.18,
|
||||
"model_group": "vision-segmenter",
|
||||
"model_version": "v1.0.0"
|
||||
}
|
||||
]
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 6.4 错误与降级响应
|
||||
|
||||
如果模型制品未安装:
|
||||
|
||||
```json
|
||||
{
|
||||
"results": {
|
||||
"detections": [],
|
||||
"segments": []
|
||||
},
|
||||
"warnings": [
|
||||
{
|
||||
"code": "MODEL_ARTIFACT_MISSING",
|
||||
"message": "vision-detector 模型制品未安装,当前未执行真实推理"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
页面必须用明显状态展示该警告。
|
||||
|
||||
### 6.5 创建完整结果视频任务
|
||||
|
||||
```http
|
||||
POST /api/v1/video-demo/export-jobs
|
||||
Content-Type: multipart/form-data
|
||||
```
|
||||
|
||||
字段:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
| --- | --- | --- |
|
||||
| `video` | file | 完整原始视频文件 |
|
||||
| `detect_enabled` | boolean | 是否写入检测结果 |
|
||||
| `segment_enabled` | boolean | 是否写入分割结果 |
|
||||
| `confidence_threshold` | number | 检测置信度阈值 |
|
||||
| `mask_threshold` | number | 分割阈值 |
|
||||
| `max_inference_width` | number | 推理输入最大宽度 |
|
||||
| `output_fps_policy` | string | `source` 保持源视频 FPS,`fixed` 使用指定输出 FPS |
|
||||
| `analysis_stride` | number | 每隔多少帧执行一次模型推理,默认 1 |
|
||||
| `reuse_last_result` | boolean | 非推理帧是否复用上一帧结果,默认 true |
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "video-run-20260801-030512",
|
||||
"status": "queued",
|
||||
"output_dir": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512",
|
||||
"status_url": "/api/v1/video-demo/export-jobs/video-run-20260801-030512"
|
||||
}
|
||||
```
|
||||
|
||||
### 6.6 查询完整结果视频任务
|
||||
|
||||
```http
|
||||
GET /api/v1/video-demo/export-jobs/{run_id}
|
||||
```
|
||||
|
||||
响应:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "video-run-20260801-030512",
|
||||
"status": "running",
|
||||
"progress": {
|
||||
"processed_frames": 420,
|
||||
"total_frames": 2400,
|
||||
"percent": 17.5,
|
||||
"elapsed_seconds": 34.2,
|
||||
"eta_seconds": 161.3
|
||||
},
|
||||
"outputs": {
|
||||
"annotated_video": null,
|
||||
"results_json": null,
|
||||
"metadata_json": null,
|
||||
"log": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/run.log"
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
完成后:
|
||||
|
||||
```json
|
||||
{
|
||||
"run_id": "video-run-20260801-030512",
|
||||
"status": "succeeded",
|
||||
"progress": {
|
||||
"processed_frames": 2400,
|
||||
"total_frames": 2400,
|
||||
"percent": 100
|
||||
},
|
||||
"outputs": {
|
||||
"annotated_video": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/annotated.mp4",
|
||||
"results_json": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/results.json",
|
||||
"results_jsonl": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/results.jsonl",
|
||||
"metadata_json": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/run-metadata.json",
|
||||
"log": "D:/workspace/AItrackwalker/visualization-demo/outputs/video-runs/video-run-20260801-030512/run.log"
|
||||
},
|
||||
"warnings": []
|
||||
}
|
||||
```
|
||||
|
||||
### 6.7 下载或预览本地输出文件
|
||||
|
||||
```http
|
||||
GET /api/v1/video-demo/export-jobs/{run_id}/files/{file_name}
|
||||
```
|
||||
|
||||
允许的 `file_name` 首版固定为:
|
||||
|
||||
1. `annotated.mp4`
|
||||
2. `results.json`
|
||||
3. `results.jsonl`
|
||||
4. `run-metadata.json`
|
||||
5. `run.log`
|
||||
|
||||
接口必须校验 `run_id` 和文件名,禁止路径穿越。
|
||||
|
||||
## 7. 模型选型
|
||||
|
||||
### 7.1 首选模型
|
||||
|
||||
| 功能 | 首选模型 | 原因 | 运行方式 |
|
||||
| --- | --- | --- | --- |
|
||||
| 目标检测 | `vision-detector`,PP-YOLOE-SOD-s ONNX | 项目已有模型注册、PaddleDetection 输出解析和 RailFOD23 训练基线,适合无人机铁路小目标 | ONNX Runtime GPU,输入 640 到 1280 |
|
||||
| 图像分割 | `vision-segmenter`,PP-LiteSeg-STDC1 ONNX | 轻量语义分割模型,适合实时分割路基、边坡、积水、裂缝等区域 | ONNX Runtime GPU,输入 512 到 1024 |
|
||||
|
||||
说明:
|
||||
|
||||
1. `vision-detector` 当前项目文档中已有 RailFOD23 PP-YOLOE-SOD-s 训练记录,但生产可用仍依赖 ONNX 导出、一致性验证和模型制品安装。
|
||||
2. `vision-segmenter` 已有注册位和解析器,真实效果依赖铁路现场或目标场景分割数据微调后的模型制品。
|
||||
3. 页面不能把基线回退结果当成真实模型结果。响应中必须保留 `execution_mode`、`artifact_installed`、`fallback_reason`。
|
||||
|
||||
### 7.2 通用演示备用模型
|
||||
|
||||
如果首版需要覆盖手机随拍视频中的人员、车辆、常见物体,并且项目铁路模型制品尚未就绪,可以增加一组独立的演示模型注册位:
|
||||
|
||||
| 功能 | 备用模型 | 用途 | 说明 |
|
||||
| --- | --- | --- | --- |
|
||||
| 通用检测 | YOLOv8n/YOLOv8s ONNX 或同级轻量 COCO 检测模型 | 手机视频、通用航拍视频的人员和车辆检测 | 只作为演示模型,不替代铁路业务模型 |
|
||||
| 通用分割 | YOLOv8n-seg/YOLOv8s-seg ONNX 或同级轻量实例分割模型 | 通用物体实例掩膜叠加 | 比 SAM 更适合实时视频开关 |
|
||||
|
||||
备用模型建议放在:
|
||||
|
||||
```text
|
||||
runtime/models/demo/
|
||||
├── object-detector/
|
||||
│ ├── model.onnx
|
||||
│ ├── labels.txt
|
||||
│ └── model-card.md
|
||||
└── instance-segmenter/
|
||||
├── model.onnx
|
||||
├── labels.txt
|
||||
└── model-card.md
|
||||
```
|
||||
|
||||
### 7.3 为什么首版不选 SAM
|
||||
|
||||
SAM 类模型更适合用户点击、框选或文本提示后的高质量交互分割。当前需求是视频播放过程中打开开关即实时处理,核心指标是端到端延迟、稳定帧率和叠加可读性。轻量语义分割或实例分割模型更贴合首版目标。
|
||||
|
||||
## 8. 实时性能策略
|
||||
|
||||
### 8.1 前端节流
|
||||
|
||||
1. 同一功能只允许一个未完成请求在飞行中。
|
||||
2. 如果后端尚未返回,下一帧直接丢弃,不排队。
|
||||
3. 检测和分割使用独立帧率:检测默认更高,分割默认更低。
|
||||
4. 视频暂停、隐藏标签页或拖动进度条时停止抽帧。
|
||||
5. 每次请求带 `timestamp_ms`,前端只渲染与当前播放时间接近的结果。
|
||||
|
||||
### 8.2 输入缩放
|
||||
|
||||
1. 视频显示可以保持原始清晰度,推理帧默认缩放到最大宽度 960。
|
||||
2. 无人机 4K 视频不直接以原始 4K 输入推理。
|
||||
3. 检测小目标场景可允许切换到 1280,但 UI 要提示会增加延迟。
|
||||
4. 分割默认 512 或 1024,优先保证稳定帧率。
|
||||
|
||||
### 8.3 后端并发
|
||||
|
||||
1. `vision-inference` 继续使用模型懒加载和最大并发控制。
|
||||
2. GPU 模式建议单进程、单 GPU、有限并发,避免多个请求同时抢占显存。
|
||||
3. 模型预热后保留最近使用模型,避免频繁加载/卸载。
|
||||
4. 检测和分割同时开启时,首版可顺序推理;后续再做并行或 TensorRT 优化。
|
||||
|
||||
### 8.4 目标性能
|
||||
|
||||
| 模式 | 默认输入 | 目标体验 |
|
||||
| --- | --- | --- |
|
||||
| RTX 3090,检测 | 960 宽 | 6 到 10 FPS,端到端延迟小于 120ms |
|
||||
| RTX 3090,分割 | 768 或 960 宽 | 2 到 5 FPS,端到端延迟小于 250ms |
|
||||
| RTX 3090,检测+分割 | 960 宽 | 检测保持较高频,分割低频刷新 |
|
||||
| CPU 降级 | 640 宽 | 1 到 2 FPS,仅用于功能验证 |
|
||||
|
||||
### 8.5 完整视频生成策略
|
||||
|
||||
1. 完整视频任务可以比实时预览慢,但必须有可见进度。
|
||||
2. 默认 `analysis_stride=1`,逐帧推理并写入完整结果;当视频过长或分割开启后耗时过高时,可选择每 2 到 5 帧推理一次并复用最近结果。
|
||||
3. 输出视频默认保持源视频分辨率和 FPS;推理帧可缩放,绘制结果再映射回源视频坐标。
|
||||
4. 使用 `mp4v` 或部署环境可用的 H.264 编码输出 `annotated.mp4`。如果 H.264 编码器不可用,降级为 `mp4v` 并在 `run-metadata.json` 记录编码器。
|
||||
5. 每处理 N 帧写入一次进度文件,服务重启后仍能读取已有任务状态。
|
||||
6. 输出目录按任务隔离,避免多次导出互相覆盖。
|
||||
|
||||
## 9. 前端渲染细节
|
||||
|
||||
### 9.1 叠加层坐标
|
||||
|
||||
后端统一返回归一化坐标,前端根据视频当前渲染尺寸转换:
|
||||
|
||||
```ts
|
||||
const x = normalizedX * renderedVideoWidth;
|
||||
const y = normalizedY * renderedVideoHeight;
|
||||
```
|
||||
|
||||
如果视频使用 `object-fit: contain`,需要计算黑边偏移:
|
||||
|
||||
```text
|
||||
drawX = videoOffsetX + normalizedX * actualVideoDrawWidth
|
||||
drawY = videoOffsetY + normalizedY * actualVideoDrawHeight
|
||||
```
|
||||
|
||||
### 9.2 叠加层分层
|
||||
|
||||
```text
|
||||
Video 元素
|
||||
Segmentation Canvas 半透明掩膜
|
||||
Detection Canvas 检测框和标签
|
||||
Interaction Layer hover、选中、结果高亮
|
||||
```
|
||||
|
||||
分割掩膜透明度默认 0.35,检测框和标签始终在最上层,避免被掩膜遮挡。
|
||||
|
||||
### 9.3 结果生命周期
|
||||
|
||||
1. 每个结果绑定 `timestamp_ms`。
|
||||
2. 当前播放时间距离结果超过 500ms 时隐藏或淡出。
|
||||
3. 拖动进度条后清空所有未确认结果。
|
||||
4. 视频文件更换后清空会话、请求、模型状态以外的页面状态。
|
||||
|
||||
## 10. 后端实现细节
|
||||
|
||||
### 10.1 复用现有运行时
|
||||
|
||||
现有 `CpuVisionRuntime` 已具备:
|
||||
|
||||
1. 模型注册表读取。
|
||||
2. ONNX Runtime Session 懒加载。
|
||||
3. OpenCV 图像解码。
|
||||
4. 检测框解析和语义分割 polygon 解析。
|
||||
5. 模型缺失时的受控回退。
|
||||
|
||||
新增单帧接口时建议优先复用这些能力,避免另起一套推理引擎。
|
||||
|
||||
### 10.2 建议新增方法
|
||||
|
||||
在运行时层增加数组输入方法,减少把帧写入临时文件的开销:
|
||||
|
||||
```python
|
||||
def infer_image_array(
|
||||
self,
|
||||
image: np.ndarray,
|
||||
model_group: str,
|
||||
parameters: dict[str, Any],
|
||||
model_version: str | None = None,
|
||||
) -> RuntimeOutcome:
|
||||
...
|
||||
```
|
||||
|
||||
HTTP 接口负责把上传的 JPEG/PNG 解码为 `np.ndarray`,然后调用该方法。
|
||||
|
||||
### 10.3 结果统一
|
||||
|
||||
当前 `InferenceResult.geometry` 支持 `BBox` 和 `Polygon`。视频演示接口可以将其转换为前端更直接的结构:
|
||||
|
||||
| 后端 geometry | 前端字段 |
|
||||
| --- | --- |
|
||||
| `{"type": "BBox", "coordinates": [x1,y1,x2,y2]}` | `bbox` |
|
||||
| `{"type": "Polygon", "coordinates": [[[x,y],...]]}` | `polygon` |
|
||||
|
||||
### 10.4 完整结果视频本地存储
|
||||
|
||||
输出根目录:
|
||||
|
||||
```text
|
||||
visualization-demo/outputs/video-runs/
|
||||
└── <run_id>/
|
||||
├── source.mp4
|
||||
├── annotated.mp4
|
||||
├── results.json
|
||||
├── results.jsonl
|
||||
├── run-metadata.json
|
||||
└── run.log
|
||||
```
|
||||
|
||||
文件说明:
|
||||
|
||||
| 文件 | 说明 |
|
||||
| --- | --- |
|
||||
| `source.mp4` | 上传后的原始视频副本;如用户不希望保存原始视频,可在任务完成后自动删除 |
|
||||
| `annotated.mp4` | 带检测框、类别、置信度、分割掩膜和时间戳的完整结果视频 |
|
||||
| `results.json` | 汇总结果,包含视频信息、模型信息、总体统计和逐帧结果索引 |
|
||||
| `results.jsonl` | 每行一帧或一个推理时间点,便于增量写入和中断恢复 |
|
||||
| `run-metadata.json` | 运行参数、模型版本、阈值、输入缩放、Provider、开始/结束时间 |
|
||||
| `run.log` | 任务日志、警告、异常和性能统计 |
|
||||
|
||||
建议默认保存原始视频副本,便于结果复核;同时在页面提供“任务完成后删除原始视频”的选项。输出目录应加入 Git 忽略规则,避免把视频和结果文件提交进仓库。
|
||||
|
||||
### 10.5 视频叠加渲染规则
|
||||
|
||||
1. 检测框颜色按类别稳定分配;标签显示类别、置信度和目标编号。
|
||||
2. 分割掩膜使用半透明填充,边界使用同类高亮描边。
|
||||
3. 检测框绘制在分割掩膜之上。
|
||||
4. 左上角可绘制时间戳、模型版本、Provider 和当前帧号。
|
||||
5. 右下角可绘制“Demo / 非生产告警”水印,避免演示视频被误用为生产判定。
|
||||
6. 输出视频中的中文字体优先使用系统字体;如果 OpenCV 环境不支持中文绘制,则标签使用英文类别名或 PIL 字体渲染后合成。
|
||||
|
||||
## 11. 安全与资源约束
|
||||
|
||||
1. 实时预览默认不上传完整视频,只上传抽取后的单帧。
|
||||
2. 只有用户点击“生成结果视频”时才上传完整视频,并在页面展示本地保存目录。
|
||||
3. 单帧上传大小限制建议 2MB。
|
||||
4. 页面接受的视频文件大小建议限制为 2GB,并在前端提示浏览器解码能力限制。
|
||||
5. 后端拒绝非图像帧、超大帧和异常 MIME。
|
||||
6. 完整视频任务必须限制输出根目录,禁止用户指定任意本地写入路径。
|
||||
7. 输出视频、原始视频副本和 JSON 结果不进入 Git,使用 `visualization-demo/outputs/` 本地保存。
|
||||
8. 模型文件不进入 Git,使用 `runtime/models/` 或部署挂载目录。
|
||||
9. 浏览器页面销毁时释放 `ObjectURL`,避免内存泄漏。
|
||||
|
||||
## 12. 实施计划
|
||||
|
||||
### 阶段 1:文档与目录
|
||||
|
||||
1. 新建 `visualization-demo/`。
|
||||
2. 输出本设计文档。
|
||||
|
||||
### 阶段 2:前端静态演示台
|
||||
|
||||
1. 新增 `/visualization-demo` 路由。
|
||||
2. 实现视频上传、播放、暂停、进度条适配。
|
||||
3. 实现检测和分割开关,但先只展示空结果和运行状态。
|
||||
4. 加入 canvas 叠加层尺寸同步。
|
||||
|
||||
### 阶段 3:后端单帧推理接口
|
||||
|
||||
1. 新增 `GET /api/v1/video-demo/capabilities`。
|
||||
2. 新增 `POST /api/v1/video-demo/warmup`。
|
||||
3. 新增 `POST /api/v1/video-demo/infer-frame`。
|
||||
4. 复用现有模型注册表、ONNX Runtime 和解析器。
|
||||
5. 对模型缺失、Provider 不可用、输入错误输出明确警告。
|
||||
|
||||
### 阶段 4:前后端联调
|
||||
|
||||
1. 前端抽帧并发送 JPEG。
|
||||
2. 后端返回检测框和分割 polygon。
|
||||
3. 前端按视频显示尺寸叠加结果。
|
||||
4. 实现丢帧、取消、拖动进度清理和延迟统计。
|
||||
|
||||
### 阶段 5:模型制品与 GPU 验证
|
||||
|
||||
1. 安装或挂载 `vision-detector` ONNX 制品。
|
||||
2. 安装或挂载 `vision-segmenter` ONNX 制品。
|
||||
3. 设置 `RAIL_MODEL_REGISTRY=infra/model-registry/server-models.json`。
|
||||
4. 设置 `RAIL_MODEL_DIR=runtime/models` 或实际模型挂载目录。
|
||||
5. 设置 `RAIL_EXECUTION_PROVIDER=CUDAExecutionProvider`。
|
||||
6. 验证 `/api/v1/runtime` 中 `execution_provider_ready=true`。
|
||||
|
||||
### 阶段 6:体验打磨
|
||||
|
||||
1. 加入模型状态条、降级提示、实时 FPS 和延迟。
|
||||
2. 加入类别筛选、结果 hover 高亮。
|
||||
3. 加入截图导出或当前帧结果 JSON 导出。
|
||||
4. 加入页面级错误恢复。
|
||||
|
||||
### 阶段 7:完整结果视频本地导出
|
||||
|
||||
1. 新增 `POST /api/v1/video-demo/export-jobs`。
|
||||
2. 新增 `GET /api/v1/video-demo/export-jobs/{run_id}`。
|
||||
3. 新增 `GET /api/v1/video-demo/export-jobs/{run_id}/files/{file_name}`。
|
||||
4. 实现 `video_export_runtime.py`,用 OpenCV 读取完整视频、调用模型推理、绘制叠加层并写入 `annotated.mp4`。
|
||||
5. 保存 `results.json`、`results.jsonl`、`run-metadata.json` 和 `run.log`。
|
||||
6. 前端增加任务进度、取消入口、完成后本地路径展示和下载按钮。
|
||||
7. 为 `visualization-demo/outputs/` 增加 Git 忽略规则。
|
||||
|
||||
## 13. 验收标准
|
||||
|
||||
| 类别 | 标准 |
|
||||
| --- | --- |
|
||||
| 视频播放 | 上传 `mp4` 后可播放、暂停、拖动、重新上传 |
|
||||
| 检测开关 | 打开后出现真实检测请求;关闭后停止请求并清除或保留最后结果可配置 |
|
||||
| 分割开关 | 打开后出现真实分割请求;掩膜与视频画面位置一致 |
|
||||
| 双开关 | 检测和分割可同时开启,页面不卡死,请求不无限排队 |
|
||||
| 模型状态 | 模型缺失、CPU 降级、GPU Provider 不可用都有明确提示 |
|
||||
| 性能 | RTX 3090 下 960 宽输入检测体验达到 6 FPS 以上 |
|
||||
| 坐标准确 | 改变窗口大小、视频比例或全屏时叠加层仍对齐 |
|
||||
| 资源释放 | 换视频、离开页面后无明显内存持续增长 |
|
||||
| 结果视频 | 点击生成后可在本地目录得到 `annotated.mp4`,视频中包含检测框和分割掩膜 |
|
||||
| 结果文件 | 同一任务目录包含 `results.json`、`results.jsonl`、`run-metadata.json` 和 `run.log` |
|
||||
| 任务进度 | 长视频处理时页面可查看进度、耗时、预计剩余时间和失败原因 |
|
||||
|
||||
## 14. 测试计划
|
||||
|
||||
### 14.1 前端测试
|
||||
|
||||
1. 抽帧调度:请求未返回时不排队。
|
||||
2. 视频尺寸变化:不同宽高比视频下 overlay 对齐。
|
||||
3. 开关状态:快速开关不会留下后台请求循环。
|
||||
4. 错误展示:后端 500、模型缺失、网络断开均有状态提示。
|
||||
|
||||
### 14.2 后端测试
|
||||
|
||||
1. `capabilities` 在模型存在和不存在时均返回可解释状态。
|
||||
2. `infer-frame` 接收 JPEG/PNG 并拒绝非法文件。
|
||||
3. 检测结果 bbox 保持 0 到 1 归一化。
|
||||
4. 分割 polygon 点位保持 0 到 1 归一化。
|
||||
5. GPU Provider 不可用时降级到 CPU 或返回明确错误。
|
||||
6. `export-jobs` 能生成本地任务目录并拒绝非法文件名访问。
|
||||
7. 完整视频生成中断或失败时保留 `run.log` 和错误状态。
|
||||
|
||||
### 14.3 联调测试
|
||||
|
||||
1. 无人机航拍视频:检查小目标框是否稳定。
|
||||
2. 手机人物/车辆视频:如果启用通用备用模型,检查常见类别是否可见。
|
||||
3. 4K 视频:验证缩放推理和叠加对齐。
|
||||
4. 长视频:连续播放 10 分钟观察内存、显存和接口延迟。
|
||||
5. 完整结果视频:对 30 秒、3 分钟、10 分钟样例分别生成 `annotated.mp4`,检查音视频时长、画面同步和输出文件完整性。
|
||||
|
||||
## 15. 主要风险与对策
|
||||
|
||||
| 风险 | 影响 | 对策 |
|
||||
| --- | --- | --- |
|
||||
| 项目铁路模型 ONNX 制品尚未就绪 | 无法展示真实铁路检测/分割 | 页面显示模型缺失;可接入通用演示模型临时验证交互 |
|
||||
| 手机视频和铁路模型类别不匹配 | 检测不到普通物体 | 使用通用备用模型作为演示 profile |
|
||||
| 4K 视频抽帧过大 | 延迟高、显存压力大 | 前端缩放到 640/960/1280 后上传 |
|
||||
| 检测和分割同时开启导致卡顿 | 页面体验下降 | 独立 FPS、单请求在飞、分割低频刷新 |
|
||||
| 视频编码浏览器不支持 | 无法播放部分 `mov` | 页面提示转换为 H.264 MP4 或 WebM |
|
||||
| 坐标叠加错位 | 演示可信度下降 | 统一归一化坐标并处理 `object-fit` 黑边 |
|
||||
| 完整视频导出耗时较长 | 用户以为任务卡死 | 后台任务、进度文件、预计剩余时间和日志 |
|
||||
| 输出视频体积过大 | 占用本地磁盘 | 输出目录配额、任务清理按钮、可选删除源视频 |
|
||||
| OpenCV 编码器不可用 | 无法生成 H.264 MP4 | 检测可用编码器,降级 `mp4v` 并记录到元数据 |
|
||||
|
||||
## 16. 后续可扩展方向
|
||||
|
||||
1. 增加 WebSocket 流式推理,减少 HTTP multipart 开销。
|
||||
2. 增加视频片段离线分析任务,输出时间轴事件。
|
||||
3. 增加目标跟踪,在检测低帧率下保持框连续。
|
||||
4. 增加 ROI 选择,只对画面局部做高分辨率推理。
|
||||
5. 增加模型 profile 切换:铁路模型、通用模型、CPU 快速验证、GPU 高精度。
|
||||
6. 增加当前帧截图和标注结果导出,供样本回流或模型测试工作台复用。
|
||||
7. 增加批量视频导出队列,对多个本地视频连续生成带检测/分割结果的本地文件。
|
||||
|
||||
## 17. 推荐首版落地结论
|
||||
|
||||
首版采用“浏览器本地播放视频 + Canvas 抽帧 + FastAPI 单帧推理 + ONNX Runtime GPU + Canvas 结果叠加”的实时预览方案,并增加“完整结果视频生成”后台任务:用户确认后上传完整视频,后端逐帧或按步长推理,把检测框和分割掩膜渲染进 `annotated.mp4`,保存到 `visualization-demo/outputs/video-runs/<run_id>/`。
|
||||
|
||||
模型优先复用项目已有的 `vision-detector` 与 `vision-segmenter` 注册位:检测使用 PP-YOLOE-SOD-s,分割使用 PP-LiteSeg-STDC1。考虑到本机已确认 RTX 3090,默认目标运行在 `CUDAExecutionProvider`;当模型制品或 GPU Provider 不可用时,页面必须显示降级原因。
|
||||
|
||||
该方案工程改动小、与现有仓库贴合,并且能自然扩展到后续 WebSocket、目标跟踪、离线分析和样本回流。
|
||||
Binary file not shown.
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user