Compare commits

4 Commits
67 changed files with 7581 additions and 2902 deletions
+429 -9
View File
@@ -3,6 +3,7 @@ from __future__ import annotations
import base64 import base64
import json import json
import os import os
import sys
import threading import threading
import time import time
from collections import OrderedDict from collections import OrderedDict
@@ -17,8 +18,37 @@ import numpy as np
from .schemas import Resource 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: try:
import onnxruntime as ort import onnxruntime as ort
if hasattr(ort, "preload_dlls"):
ort.preload_dlls()
except ImportError: # pragma: no cover - exercised by the lightweight source test environment except ImportError: # pragma: no cover - exercised by the lightweight source test environment
ort = None ort = None
@@ -28,6 +58,44 @@ except ImportError: # pragma: no cover - exercised by the lightweight source te
Minio = None 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) @dataclass(frozen=True)
class CpuRuntimeSettings: class CpuRuntimeSettings:
profile: str profile: str
@@ -45,13 +113,13 @@ class CpuRuntimeSettings:
def from_env(cls) -> "CpuRuntimeSettings": def from_env(cls) -> "CpuRuntimeSettings":
return cls( return cls(
profile=os.getenv("RAIL_RUNTIME_PROFILE", "cpu-local"), profile=os.getenv("RAIL_RUNTIME_PROFILE", "cpu-local"),
registry_path=Path(os.getenv("RAIL_MODEL_REGISTRY", "/app/config/model-registry.json")), registry_path=_default_registry_path(),
model_dir=Path(os.getenv("RAIL_MODEL_DIR", "/models")), model_dir=_default_model_dir(),
execution_provider=os.getenv("RAIL_EXECUTION_PROVIDER", "CPUExecutionProvider"), execution_provider=os.getenv("RAIL_EXECUTION_PROVIDER", "CPUExecutionProvider"),
intra_op_threads=max(1, int(os.getenv("RAIL_INTRA_OP_THREADS", "4"))), 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"))), 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_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, max_resource_bytes=max(1, int(os.getenv("RAIL_MAX_RESOURCE_MB", "64"))) * 1024 * 1024,
fallback_mode=os.getenv("RAIL_FALLBACK_MODE", "baseline"), fallback_mode=os.getenv("RAIL_FALLBACK_MODE", "baseline"),
) )
@@ -154,6 +222,17 @@ class ModelRegistry:
"parser": "paddle-detection", "parser": "paddle-detection",
"input_size": 640, "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_group": "vision-segmenter",
"model_version": "cpu-v1.0.0", "model_version": "cpu-v1.0.0",
@@ -166,6 +245,17 @@ class ModelRegistry:
"mean": [0.5, 0.5, 0.5], "mean": [0.5, 0.5, 0.5],
"std": [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_group": "thermal-analyzer",
"model_version": "cpu-v1.0.0", "model_version": "cpu-v1.0.0",
@@ -419,6 +509,10 @@ class LazyOnnxRuntime:
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
if spec.parser == "semantic-segmentation": if spec.parser == "semantic-segmentation":
return self._parse_segmentation(outputs, spec, parameters) 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) return self._parse_detection(outputs, spec, transform, parameters)
def _parse_detection( def _parse_detection(
@@ -475,6 +569,289 @@ class LazyOnnxRuntime:
break break
return results 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( def _parse_segmentation(
self, outputs: list[np.ndarray], spec: ModelSpec, parameters: dict[str, Any] self, outputs: list[np.ndarray], spec: ModelSpec, parameters: dict[str, Any]
) -> list[dict[str, Any]]: ) -> list[dict[str, Any]]:
@@ -495,20 +872,26 @@ class LazyOnnxRuntime:
results: list[dict[str, Any]] = [] results: list[dict[str, Any]] = []
for class_id in (int(item) for item in np.unique(mask) if int(item) != 0): 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) binary = np.where(mask == class_id, 255, 0).astype(np.uint8)
contours, _ = cv2.findContours(binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) component_count, component_labels, stats, _ = cv2.connectedComponentsWithStats(binary, 8)
for contour in sorted(contours, key=cv2.contourArea, reverse=True): for component_id in range(1, component_count):
area = cv2.contourArea(contour) area = int(stats[component_id, cv2.CC_STAT_AREA])
if area < minimum_area: if area < minimum_area:
continue 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)) epsilon = float(parameters.get("contour_simplification", 0.8))
simplified = cv2.approxPolyDP(contour, max(0.5, epsilon), True) simplified = cv2.approxPolyDP(contour, max(0.5, epsilon), True)
points = [ points = [
[self._clip(float(point[0][0]) / mask.shape[1]), self._clip(float(point[0][1]) / mask.shape[0])] [self._clip(float(point[0][0]) / mask.shape[1]), self._clip(float(point[0][1]) / mask.shape[0])]
for point in simplified for point in simplified
] ]
if len(points) < 3: if len(points) >= 3:
continue points.append(points[0])
points.append(points[0]) else:
points = []
category = labels[class_id] if class_id < len(labels) else f"class-{class_id}" category = labels[class_id] if class_id < len(labels) else f"class-{class_id}"
results.append( results.append(
{ {
@@ -516,10 +899,34 @@ class LazyOnnxRuntime:
"confidence": 1.0, "confidence": 1.0,
"geometry": {"type": "Polygon", "coordinates": [points], "coordinate_space": "normalized"}, "geometry": {"type": "Polygon", "coordinates": [points], "coordinate_space": "normalized"},
"measurements": {"area_ratio": round(area / mask.size, 6)}, "measurements": {"area_ratio": round(area / mask.size, 6)},
"mask": self._encode_binary_mask(component_mask),
} }
) )
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,
}
def _labels(self, spec: ModelSpec) -> list[str]: def _labels(self, spec: ModelSpec) -> list[str]:
path = spec.labels_path(self.settings.model_dir) path = spec.labels_path(self.settings.model_dir)
if path and path.is_file(): if path and path.is_file():
@@ -581,6 +988,19 @@ class CpuVisionRuntime:
) )
return self.models.predict(model_group, image, parameters, model_version) 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]: def status(self) -> dict[str, Any]:
return self.models.status() 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}
+5
View File
@@ -10,9 +10,14 @@ from .schemas import (
ModelTestInferenceRequest, ModelTestInferenceRequest,
ModelTestInferenceResponse, 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") app = FastAPI(title="Rail UAV Vision Inference Service", version="0.1.0")
engine = VisionInferenceEngine() 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) @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 fastapi==0.115.5
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
pydantic==2.10.2 pydantic==2.10.2
python-multipart==0.0.19
opencv-python-headless==4.10.0.84 opencv-python-headless==4.10.0.84
numpy==2.1.3 numpy==2.1.3
onnxruntime-gpu==1.20.1 onnxruntime-gpu[cuda,cudnn]==1.22.0
minio==7.2.12 minio==7.2.12
@@ -1,6 +1,7 @@
fastapi==0.115.5 fastapi==0.115.5
uvicorn[standard]==0.32.1 uvicorn[standard]==0.32.1
pydantic==2.10.2 pydantic==2.10.2
python-multipart==0.0.19
opencv-python-headless==4.10.0.84 opencv-python-headless==4.10.0.84
numpy==2.1.3 numpy==2.1.3
onnxruntime==1.20.1 onnxruntime==1.20.1
@@ -51,10 +51,12 @@ def test_cpu_runtime_reports_registered_visual_models():
assert body["profile"] == "cpu-local" assert body["profile"] == "cpu-local"
assert body["runtime_available"] is True assert body["runtime_available"] is True
assert body["max_concurrency"] == 1 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"]} groups = {item["model_group"] for item in body["models"]}
assert {"vision-detector", "vision-segmenter", "thermal-analyzer"}.issubset(groups) 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 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"])
+128
View File
@@ -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 万元买断价;买断时交付哪个“当前迭代版本”;买断后新增维护、升级和适配如何收费?
- 方案三的软件知识产权是否转入合资公司;如果项目未落地,如何触发资产返还/回转或清算;双方确认我方不承担收购合资公司股权义务。
@@ -55,8 +55,8 @@ function visibleCollection() {
function addLayers() { function addLayers() {
if (!map || map.getSource("railway-features")) return; if (!map || map.getSource("railway-features")) return;
map.addSource("railway-features", { type: "geojson", data: visibleCollection() as GeoJSON.FeatureCollection }); 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-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": "#17365d", "line-width": 5, "line-opacity": 0.82 } }); 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: "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: "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 } }); 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; const properties = event.features?.[0]?.properties as Row | undefined;
if (properties) emit("select", properties); 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("mouseenter", "alarm-point", () => { if (map) map.getCanvas().style.cursor = "pointer"; });
map.on("mouseleave", "alarm-point", () => { if (map) map.getCanvas().style.cursor = ""; }); 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() { function fitFeatures() {
if (!map) return; if (!map) return;
const bounds = new LngLatBounds(); 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 }); 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-color", ["case", ["==", ["to-string", ["id"]], props.selectedId], "#111827", "#ffffff"]);
map.setPaintProperty("alarm-point", "circle-stroke-width", ["case", ["==", ["to-string", ["id"]], props.selectedId], 4, 2]); 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(); if (fit) fitFeatures();
} }
@@ -103,6 +121,6 @@ onMounted(async () => {
}); });
watch(() => props.featureCollection, () => updateData(true), { deep: true }); watch(() => props.featureCollection, () => updateData(true), { deep: true });
watch(() => props.layers, () => updateData(false), { deep: true }); watch(() => props.layers, () => updateData(false), { deep: true });
watch(() => props.selectedId, () => updateData(false)); watch(() => props.selectedId, () => updateData(true));
onBeforeUnmount(() => { map?.remove(); map = null; }); onBeforeUnmount(() => { map?.remove(); map = null; });
</script> </script>
+2
View File
@@ -9,6 +9,7 @@ import {
Files, Files,
FolderOpened, FolderOpened,
Guide, Guide,
MagicStick,
MapLocation, MapLocation,
Monitor, Monitor,
Position, Position,
@@ -51,6 +52,7 @@ export const navigationGroups: NavigationGroup[] = [
label: "研判", label: "研判",
items: [ items: [
{ path: "/analysis", label: "智能分析", description: "推理与规则", icon: Cpu }, { path: "/analysis", label: "智能分析", description: "推理与规则", icon: Cpu },
{ path: "/visualization-demo", label: "视频 AI 演示", description: "上传视频与本地导出", icon: MagicStick },
{ path: "/alarms", label: "告警中心", description: "告警研判", icon: Bell } { path: "/alarms", label: "告警中心", description: "告警研判", icon: Bell }
] ]
}, },
+1
View File
@@ -19,6 +19,7 @@ const router = createRouter({
{ path: "uav-operations", name: "uav-operations", meta: { title: "无人机运行" }, component: () => import("../views/uav-operations/UavOperationsView.vue") }, { 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: "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: "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: "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: "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") }, { path: "algorithm-assets/:entityType?/:entityId?", name: "algorithm-assets", meta: { title: "样本与模型" }, component: () => import("../views/algorithm-assets/AlgorithmAssetsView.vue") },
+17 -1
View File
@@ -53,8 +53,19 @@ export async function analysisResults() {
return data.data.results; 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) { 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; return data.data;
} }
@@ -113,6 +124,11 @@ export async function serviceHealth() {
return data; return data;
} }
export async function operationsHealth() {
const { data } = await client.get("/operations/health", { timeout: 15000 });
return data.data;
}
export async function aiRuntimeStatus() { export async function aiRuntimeStatus() {
const { data } = await client.get("/operations/ai-runtime"); const { data } = await client.get("/operations/ai-runtime");
return data.data; return data.data;
+109
View File
@@ -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)}`;
}
+7 -3
View File
@@ -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="置信度" 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="位置" 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="状态" 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="工单" 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="!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="操作" 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-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-drawer v-model="detailVisible" title="告警研判" size="min(760px, 94vw)">
<el-skeleton v-if="detailLoading" :rows="10" animated /> <el-skeleton v-if="detailLoading" :rows="10" animated />
<AlarmEvidencePanel v-else-if="evidence.alarm" :evidence="evidence"> <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> </AlarmEvidencePanel>
<el-empty v-else description="未获取到告警证据" /> <el-empty v-else description="未获取到告警证据" />
</el-drawer> </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 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: "保留抑制原因" }]); 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 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 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 matchTab(row: Row) { return matchesTab(row, activeTab.value); }
function tabCount(tab: string) { return rows.value.filter((row) => matchesTab(row, tab)).length; } function tabCount(tab: string) { return rows.value.filter((row) => matchesTab(row, tab)).length; }
+7 -4
View File
@@ -9,7 +9,7 @@
<el-tabs v-model="activeTab" class="secondary-tabs"> <el-tabs v-model="activeTab" class="secondary-tabs">
<el-tab-pane :label="`分析任务 ${jobRows.length}`" name="jobs"> <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> <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>
<el-tab-pane :label="`AI 结果 ${resultRows.length}`" name="results"> <el-tab-pane :label="`AI 结果 ${resultRows.length}`" name="results">
@@ -23,7 +23,7 @@
<el-tab-pane :label="`失败与降级 ${failedJobs.length}`" name="failed"> <el-tab-pane :label="`失败与降级 ${failedJobs.length}`" name="failed">
<el-empty v-if="failedJobs.length === 0" description="当前没有失败或降级的分析任务" /> <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-tab-pane>
</el-tabs> </el-tabs>
@@ -41,7 +41,7 @@ import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePaginati
import { alarms, analysisJobs, analysisResults, resultEvidence, runAnalysisJob } from "../../services/api"; import { alarms, analysisJobs, analysisResults, resultEvidence, runAnalysisJob } from "../../services/api";
import { safeObject, statusLabel, statusTag, type Row } from "../../types/demo-run"; 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 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 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)))); 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 arrayCount(value: unknown) { return parseArray(value).length; }
function ruleText(value: unknown) { return parseArray(value).join(" / ") || "业务规则命中"; } 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 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 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; } } 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(detailVisible, (visible) => { if (!visible && route.params.entityId) router.replace({ path: "/analysis", query: { ...route.query, tab: "results" } }); });
watch([keyword, statusFilter, sceneFilter], () => { resetJobPage(); resetResultPage(); }); watch([keyword, statusFilter, sceneFilter], () => { resetJobPage(); resetResultPage(); });
+57 -7
View File
@@ -8,6 +8,8 @@
<el-button type="primary" :disabled="!selectedTaskId" @click="exportThematicMap">导出专题图</el-button> <el-button type="primary" :disabled="!selectedTaskId" @click="exportThematicMap">导出专题图</el-button>
</PageHeader> </PageHeader>
<el-alert v-if="deepLinkLabel" class="deep-link-alert" type="info" :closable="false" show-icon title="深链定位已生效" :description="deepLinkLabel" />
<div class="filter-bar"> <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="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> <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> </div>
<section class="gis-page-layout"> <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"> <aside class="gis-detail-panel">
<template v-if="selected"> <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 :column="1" border size="small">
<el-descriptions-item label="告警编号"><span class="entity-id">{{ selected.alarm_id }}</span></el-descriptions-item> <el-descriptions-item label="告警编号"><span class="entity-id">{{ selected.alarm_id }}</span></el-descriptions-item>
<el-descriptions-item label="类别">{{ selected.category }}</el-descriptions-item> <el-descriptions-item label="类别">{{ selected.category }}</el-descriptions-item>
@@ -32,6 +34,16 @@
</el-descriptions> </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> <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>
<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" /> <el-empty v-else description="点击地图告警点查看详情" :image-size="96" />
</aside> </aside>
</section> </section>
@@ -71,7 +83,7 @@
</template> </template>
<script setup lang="ts"> <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 { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Close, Refresh } from "@element-plus/icons-vue"; 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 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 gisLayerRows = ref<Row[]>([]); const ruleSetRows = ref<Row[]>([]); const exemptionRows = ref<Row[]>([]); const geofenceRows = ref<Row[]>([]);
const featureCollection = ref<Row>({ type: "FeatureCollection", features: [] }); 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 spatialTab = ref("layers"); const spatialExplanation = ref<Row | null>(null);
const selectedTaskId = ref(String(route.query.taskId || "")); const selectedTaskId = ref(String(route.query.taskId || ""));
const exportFormat = ref("PDF"); const exportFormat = ref("PDF");
@@ -95,7 +107,15 @@ const playing = ref(false); const playbackSpeed = ref(1);
let playbackTimer: ReturnType<typeof setInterval> | null = null; let playbackTimer: ReturnType<typeof setInterval> | null = null;
const layers = reactive({ routes: true, rules: true, alarms: true }); 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 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 filteredFeatureCollection = computed(() => {
const ids = new Set(filteredAlarms.value.map((row) => String(row.alarm_id))); 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))); 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 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 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 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 }) : "-"; } function formatDate(value: unknown) { return value ? new Date(String(value)).toLocaleString("zh-CN", { hour12: false }) : "-"; }
async function loadPlayback() { async function loadPlayback() {
if (!selectedTaskId.value) return; if (!selectedTaskId.value) return;
@@ -199,7 +247,8 @@ async function explainSelected() {
spatialTab.value = "rules"; spatialTab.value = "rules";
ElMessage.success(`空间解释完成:${result.decision}`); 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); onMounted(load);
onBeforeUnmount(pausePlayback); onBeforeUnmount(pausePlayback);
</script> </script>
@@ -217,4 +266,5 @@ onBeforeUnmount(pausePlayback);
.export-format { width: 150px; } .export-format { width: 150px; }
.spatial-card { margin-top: 18px; } .spatial-card { margin-top: 18px; }
.spatial-explanation { margin-top: 14px; } .spatial-explanation { margin-top: 14px; }
.deep-link-alert { margin-bottom: 16px; }
</style> </style>
@@ -14,13 +14,19 @@
<el-tabs v-model="activeTab" class="secondary-tabs"> <el-tabs v-model="activeTab" class="secondary-tabs">
<el-tab-pane label="服务状态" name="services"> <el-tab-pane label="服务状态" name="services">
<section class="service-grid"> <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> <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> <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> </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> </div>
</section> </section>
@@ -125,7 +131,18 @@
</el-tab-pane> </el-tab-pane>
<el-tab-pane label="接口集成" name="integrations"> <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>
<el-tab-pane label="组织与责任" name="organization"> <el-tab-pane label="组织与责任" name="organization">
<div class="page-grid two-columns"> <div class="page-grid two-columns">
@@ -201,11 +218,11 @@ import {
modelArtifactJob, modelArtifactJob,
modelArtifactRuntimeOverview, modelArtifactRuntimeOverview,
modelArtifactUninstallImpact, modelArtifactUninstallImpact,
operationsHealth,
organizations as loadOrganizations, organizations as loadOrganizations,
overview, overview,
platformEvents, platformEvents,
responsibilityRules, responsibilityRules,
serviceHealth,
uninstallModelArtifact, uninstallModelArtifact,
unloadModelArtifact, unloadModelArtifact,
uploadModelArtifact uploadModelArtifact
@@ -215,7 +232,7 @@ import { safeObject, type Row } from "../../types/demo-run";
const loading = ref(false); const loading = ref(false);
const activeTab = ref("services"); const activeTab = ref("services");
const runtimeScope = ref<"active" | "all">("active"); 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 stats = ref<Row>({});
const eventRows = ref<Row[]>([]); const eventRows = ref<Row[]>([]);
const organizationRows = 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 installerInfo = computed(() => safeObject(artifactOverview.value.installer));
const installerStatus = computed(() => String(installerInfo.value.status || "unavailable")); const installerStatus = computed(() => String(installerInfo.value.status || "unavailable"));
const installerMessage = computed(() => String(installerInfo.value.message || "模型制品安装代理当前不可用,请检查 8103 服务。")); 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 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 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 executionProvider = computed(() => String(visionRuntime.value.execution_provider || spatialRuntime.value.execution_provider || "-"));
const metrics = computed(() => [ 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: `${readyCount.value}/${artifactRows.value.length}`, note: runtimeProfile.value },
{ label: "业务对象", value: Number(stats.value.tasks || 0) + Number(stats.value.resources || 0), note: "任务与资源" }, { label: "业务对象", value: Number(stats.value.tasks || 0) + Number(stats.value.resources || 0), note: "任务与资源" },
{ label: "分析结果", value: stats.value.ai_results || 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 jobStatusText = computed(() => ({ queued: "排队中", running: "执行中", completed: "已完成", failed: "失败" }[String(currentJob.value.status)] || "等待状态"));
const jobTitle = computed(() => ({ install: "模型制品安装", load: "模型加载", unload: "运行资源释放", uninstall: "模型制品卸载" }[String(currentJob.value.job_type)] || "模型制品任务")); const jobTitle = computed(() => ({ install: "模型制品安装", load: "模型加载", unload: "运行资源释放", uninstall: "模型制品卸载" }[String(currentJob.value.job_type)] || "模型制品任务"));
const latestJobMessage = computed(() => String(jobEvents.value[jobEvents.value.length - 1]?.message || "正在等待任务执行")); const latestJobMessage = computed(() => String(jobEvents.value[jobEvents.value.length - 1]?.message || "正在等待任务执行"));
const overallStatusText = computed(() => ({ UP: "全部正常", DEGRADED: "部分异常", DOWN: "不可用" }[String(operations.value.status)] || "未检查"));
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 checkedAtText = computed(() => operations.value.checked_at ? `检查于 ${formatDateTime(operations.value.checked_at)}` : "等待首次检查");
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 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" }] }]; 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) { 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 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 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 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 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() { async function load() {
loading.value = true; loading.value = true;
try { try {
const [healthData, statData, platformEventData, runtimeData, artifactData, organizationData, responsibilityData] = await Promise.all([serviceHealth(), overview(), platformEvents(), aiRuntimeStatus(), modelArtifactRuntimeOverview(), loadOrganizations(), responsibilityRules()]); const loaders: Array<{ name: string; request: Promise<unknown>; apply: (value: unknown) => void }> = [
health.value = healthData; stats.value = statData; eventRows.value = platformEventData; runtime.value = runtimeData; artifactOverview.value = artifactData; organizationRows.value = organizationData; responsibilityRows.value = responsibilityData; { 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)); } } catch (error) { ElMessage.error(errorMessage(error)); }
finally { loading.value = false; } finally { loading.value = false; }
} }
@@ -353,6 +410,7 @@ onBeforeUnmount(() => window.clearTimeout(jobTimer));
<style scoped> <style scoped>
.runtime-panel { margin-top: 18px; border-top: 1px solid var(--el-border-color-light); padding-top: 16px; } .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, .runtime-panel > footer { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
.runtime-panel > header { margin-bottom: 12px; } .runtime-panel > header { margin-bottom: 12px; }
.runtime-panel > header > div:first-child, .model-cell, .dialog-context { display: grid; gap: 3px; } .runtime-panel > header > div:first-child, .model-cell, .dialog-context { display: grid; gap: 3px; }
+1 -1
View File
@@ -84,7 +84,7 @@
<template #footer><el-button @click="objectDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveObject">创建</el-button></template> <template #footer><el-button @click="objectDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="saveObject">创建</el-button></template>
</el-dialog> </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"> <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> <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 v-for="processor in processors" :key="processor.type"><span>{{ processor.type }}</span><strong>{{ typeCount(processor.key) }}</strong><small>{{ processor.steps }}</small></div>
</div> </div>
<el-card class="workspace-card" shadow="never"><el-table :data="pagedPreprocessRows" empty-text="暂无预处理记录"> <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-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> </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 { Grid, Histogram, Picture, Refresh, Search, Upload, UploadFilled } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.vue"; import PageHeader from "../../components/common/PageHeader.vue";
import { DEFAULT_PAGE_SIZES, usePagination } from "../../composables/usePagination"; 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"; 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 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 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 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 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 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())))); 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 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 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 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; } } 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 selectFile(file: UploadFile) { uploadFile.value = file.raw || null; }
function removeFile() { uploadFile.value = 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 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("预处理与质量检查已完成"); await load(); } 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; } } 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(detailVisible, (visible) => { if (!visible && route.params.resourceId) router.replace({ path: "/resources", query: route.query }); });
watch([keyword, typeFilter, () => route.query.taskId], resetResourcePage); watch([keyword, typeFilter, () => route.query.taskId], resetResourcePage);
+95 -8
View File
@@ -6,6 +6,16 @@
<el-button type="primary" :icon="Plus" @click="createDialog = true">新建航线</el-button> <el-button type="primary" :icon="Plus" @click="createDialog = true">新建航线</el-button>
</PageHeader> </PageHeader>
<el-alert
v-if="bindingTaskId"
class="binding-alert"
type="info"
:closable="false"
show-icon
title="正在为巡检任务配置航线"
:description="`任务 ${bindingTaskId}:请选择${bindingObjectId ? '当前巡检对象的' : ''}已发布航线并点击“绑定到任务”。`"
/>
<section class="metric-strip"> <section class="metric-strip">
<div class="metric-card"><strong>{{ rows.length }}</strong><span>航线</span><small>统一航线台账</small></div> <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> <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="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="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="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-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> <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> <template #footer><el-button @click="createDialog = false">取消</el-button><el-button type="primary" :loading="saving" @click="save">创建并校验</el-button></template>
</el-dialog> </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> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, reactive, ref, watch } from "vue"; import { computed, onMounted, reactive, ref, watch } from "vue";
import axios from "axios";
import { useRoute, useRouter } from "vue-router";
import { ElMessage } from "element-plus"; import { ElMessage } from "element-plus";
import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue"; import { Delete, Plus, Refresh, Search } from "@element-plus/icons-vue";
import PageHeader from "../../components/common/PageHeader.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 { 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"; 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 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 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 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 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); 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 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 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 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 || "-"); } 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 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 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 }); } 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 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 }); }
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}%` }; } 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 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 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 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 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 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 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 loadVersions(row: Row) { versionRows.value = await routeVersions(String(row.route_id)); }
async function loadRevisionRows(row: Row) { revisionRows.value = await routeEditorRevisions(activeVersionId(row)); } 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 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 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([keyword, statusFilter], resetPage);
watch(() => generateForm.object_id, () => { watch(() => generateForm.object_id, () => {
const first = generatorOptions.value[0]; const first = generatorOptions.value[0];
if (first) generateForm.generator_type = String(first.generator_type); 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> </script>
<style scoped> <style scoped>
.version-head { margin-top: 24px; } .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> </style>
+4 -1
View File
@@ -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 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 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 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) { function configureRoute(row: Row) {
const objectId = parseIds(row.object_scope)[0]; const objectId = parseIds(row.object_scope)[0];
router.push({ path: "/routes", query: objectId ? { objectId, taskId: String(row.task_id) } : { taskId: String(row.task_id) } }); 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 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="进度" 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="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-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-tab-pane>
</el-tabs> </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> <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> <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 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" /> <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> <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 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 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 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 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 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))]); } async function loadDetail(row: Row) { [actions.value, evidence.value] = await Promise.all([workorderActions(String(row.workorder_id)), workorderEvidence(String(row.workorder_id))]); }
+5
View File
@@ -12,6 +12,11 @@ export default defineConfig({
"/actuator": { "/actuator": {
target: "http://localhost:8080", target: "http://localhost:8080",
changeOrigin: true changeOrigin: true
},
"/vision-api": {
target: "http://localhost:8101",
changeOrigin: true,
rewrite: (path) => path.replace(/^\/vision-api/, "")
} }
} }
} }
+16 -1
View File
@@ -90,6 +90,13 @@ services:
build: build:
context: ../ai-services/vision-inference context: ../ai-services/vision-inference
container_name: rail-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: ports:
- "8101:8101" - "8101:8101"
healthcheck: healthcheck:
@@ -102,6 +109,13 @@ services:
build: build:
context: ../ai-services/pointcloud-analysis context: ../ai-services/pointcloud-analysis
container_name: rail-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: ports:
- "8102:8102" - "8102:8102"
healthcheck: healthcheck:
@@ -145,7 +159,8 @@ services:
kafka: kafka:
condition: service_healthy condition: service_healthy
environment: 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_USERNAME: rail
SPRING_DATASOURCE_PASSWORD: rail SPRING_DATASOURCE_PASSWORD: rail
SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092 SPRING_KAFKA_BOOTSTRAP_SERVERS: kafka:9092
+36
View File
@@ -19,6 +19,24 @@
"scale": 0.00392156862745098, "scale": 0.00392156862745098,
"color_order": "RGB" "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_group": "vision-segmenter",
"model_version": "cpu-v1.0.0", "model_version": "cpu-v1.0.0",
@@ -37,6 +55,24 @@
"scale": 0.00392156862745098, "scale": 0.00392156862745098,
"color_order": "RGB" "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_group": "change-detector",
"model_version": "cpu-v1.0.0", "model_version": "cpu-v1.0.0",
+27 -1
View File
@@ -10,4 +10,30 @@ runs/
artifacts/ artifacts/
cache/ cache/
.env .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/
+11
View File
@@ -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.
@@ -75,6 +75,14 @@ public class CapabilityCompletionController {
return ApiResponse.ok(service.createRoute(request)); 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") @PostMapping("/route-versions/{versionId}/validate")
public ApiResponse<?> validateRoute(@PathVariable String versionId) { public ApiResponse<?> validateRoute(@PathVariable String versionId) {
return ApiResponse.ok(service.validateRouteVersion(versionId)); return ApiResponse.ok(service.validateRouteVersion(versionId));
@@ -52,6 +52,14 @@ public final class CapabilityRequests {
) { ) {
} }
public record BindTaskRouteRequest(
@NotBlank String routeId,
String expectedObjectId,
Long expectedTaskVersion,
String boundBy
) {
}
public record CreateConnectionRequest( public record CreateConnectionRequest(
@NotBlank String name, @NotBlank String name,
@NotBlank String vendorCode, @NotBlank String vendorCode,
@@ -404,6 +404,90 @@ public class CapabilityCompletionService {
return result; 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 @Transactional
public Map<String, Object> validateRouteVersion(String versionId) { public Map<String, Object> validateRouteVersion(String versionId) {
Map<String, Object> version = requireRow( Map<String, Object> version = requireRow(
@@ -846,8 +930,11 @@ public class CapabilityCompletionService {
String targetStatus = MissionStateMachine.targetForCommand(currentStatus, command); String targetStatus = MissionStateMachine.targetForCommand(currentStatus, command);
int progress = ((Number) mission.get("progress")).intValue(); int progress = ((Number) mission.get("progress")).intValue();
if ("SIMULATE_PROGRESS".equals(command)) { if ("SIMULATE_PROGRESS".equals(command)) {
if (!"FLYING".equals(currentStatus)) throw conflict("只有飞行中的模拟任务可以推进进度"); try {
progress = Math.min(100, progress + 25); progress = MissionStateMachine.advanceSimulatedProgress(currentStatus, progress);
} catch (IllegalArgumentException exception) {
throw conflict(exception.getMessage());
}
if (progress >= 100) targetStatus = "COMPLETED"; if (progress >= 100) targetStatus = "COMPLETED";
} else if (!MissionStateMachine.canTransition(currentStatus, targetStatus)) { } else if (!MissionStateMachine.canTransition(currentStatus, targetStatus)) {
throw conflict("飞行任务不能从 " + currentStatus + " 执行 " + command + "" + targetStatus); throw conflict("飞行任务不能从 " + currentStatus + " 执行 " + command + "" + targetStatus);
@@ -1100,7 +1187,7 @@ public class CapabilityCompletionService {
List<Map<String, Object>> features = new ArrayList<>(); 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'")) { 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( 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") "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(), Ids.next("wo-action"), workorderId, action, from, to, request.operatorId(),
defaultString(request.operatorName(), "现场处置人员"), request.result(), request.comment(), defaultString(request.operatorName(), "现场处置人员"), request.result(), request.comment(),
Jsonb.write(request.metadata() == null ? Map.of() : request.metadata()), Timestamp.from(now)); Jsonb.write(request.metadata() == null ? Map.of() : request.metadata()), Timestamp.from(now));
if ("closed".equals(to)) { jdbc.update("""
jdbc.update("update alarms set status='closed', updated_at=? where id=?", Timestamp.from(now), workorder.get("alarm_id")); 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)); 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); return Map.of("workorder_id", workorderId, "from_status", from, "status", to, "action", action);
} }
@@ -47,10 +47,21 @@ public final class InspectionTaskWorkflow {
return PRE_FLIGHT_STATUSES.contains(taskStatus) && !hasMission; 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) { public static String taskStatusForMission(String missionStatus) {
return switch (missionStatus) { return switch (missionStatus) {
case "FLYING", "PAUSED", "RETURNING" -> "flying"; case "FLYING", "PAUSED", "RETURNING" -> "flying";
case "COMPLETED", "UPLOADING" -> "data_uploading"; case "UPLOADING" -> "data_uploading";
case "COMPLETED" -> "completed";
case "CANCELLED" -> "cancelled"; case "CANCELLED" -> "cancelled";
case "ABORTED", "FAILED", "DISPATCH_FAILED" -> "failed"; case "ABORTED", "FAILED", "DISPATCH_FAILED" -> "failed";
default -> "dispatched"; default -> "dispatched";
@@ -37,4 +37,13 @@ public final class MissionStateMachine {
default -> throw new IllegalArgumentException("不支持的飞行任务命令: " + command); 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);
}
} }
@@ -41,4 +41,12 @@ public final class WorkOrderStateMachine {
public static String target(String action) { public static String target(String action) {
return TARGETS.get(action); return TARGETS.get(action);
} }
public static String alarmStatusFor(String workorderStatus) {
return switch (workorderStatus) {
case "created", "dispatched" -> "dispatched";
case "closed" -> "closed";
default -> "processing";
};
}
} }
@@ -240,7 +240,7 @@ public class DependencyFreeCompletionService {
owner_org_id,risk_level,inspection_cycle_days,source_crs,transform_version, owner_org_id,risk_level,inspection_cycle_days,source_crs,transform_version,
accuracy_m,status,created_at,updated_at 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=? from inspection_object_import_rows where id=?
""", """,
objectId, upper(payload.get("object_type")), payload.get("name"), payload.get("line_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) select count(*) from inspection_task_objects where object_id in (%s)
""".formatted(placeholders(objectIds.size())), Integer.class, objectIds.toArray()); """.formatted(placeholders(objectIds.size())), Integer.class, objectIds.toArray());
if (referenced > 0) throw conflict("导入对象已被任务引用,不能回滚"); if (referenced > 0) throw conflict("导入对象已被任务引用,不能回滚");
jdbc.update("""
update inspection_object_import_rows set imported_object_id=null where job_id=?
""", jobId);
if (!objectIds.isEmpty()) { if (!objectIds.isEmpty()) {
jdbc.update("delete from inspection_object_versions where object_id in (%s)".formatted(placeholders(objectIds.size())), jdbc.update("delete from inspection_object_versions where object_id in (%s)".formatted(placeholders(objectIds.size())),
objectIds.toArray()); objectIds.toArray());
jdbc.update("delete from inspection_objects where id in (%s)".formatted(placeholders(objectIds.size())), jdbc.update("delete from inspection_objects where id in (%s)".formatted(placeholders(objectIds.size())),
objectIds.toArray()); objectIds.toArray());
} }
jdbc.update("""
update inspection_object_import_rows set imported_object_id=null where job_id=?
""", jobId);
jdbc.update(""" jdbc.update("""
update inspection_object_import_jobs update inspection_object_import_jobs
set status='ROLLED_BACK',confirmed_by=?,updated_at=? set status='ROLLED_BACK',confirmed_by=?,updated_at=?
@@ -331,7 +331,8 @@ public class DependencyFreeCompletionService {
schedule_timezone,next_run_at,effective_to schedule_timezone,next_run_at,effective_to
from inspection_plans where id=? from inspection_plans where id=?
""", planId); """, 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 cursor = plan.get("next_run_at") == null
? Instant.now().truncatedTo(ChronoUnit.HOURS) ? Instant.now().truncatedTo(ChronoUnit.HOURS)
: ((Timestamp) plan.get("next_run_at")).toInstant(); : ((Timestamp) plan.get("next_run_at")).toInstant();
@@ -344,7 +345,7 @@ public class DependencyFreeCompletionService {
"scheduled_window_start", start.toString(), "scheduled_window_start", start.toString(),
"scheduled_window_end", end.toString(), "scheduled_window_end", end.toString(),
"generation_key", sha256(planId + "|" + start + "|" + end), "generation_key", sha256(planId + "|" + start + "|" + end),
"source", "SCHEDULE_PREVIEW" "source", manual ? "MANUAL_PREVIEW" : "SCHEDULE_PREVIEW"
)); ));
cursor = start; cursor = start;
} }
@@ -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());
}
}
@@ -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.api.dto.Requests;
import com.ai.trackwalker.capability.uav.InspectionTaskWorkflow; 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.Ids;
import com.ai.trackwalker.common.Jsonb; import com.ai.trackwalker.common.Jsonb;
import org.springframework.jdbc.core.JdbcTemplate; import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.kafka.core.KafkaTemplate; import org.springframework.kafka.core.KafkaTemplate;
import org.springframework.http.HttpStatus; import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service; 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.annotation.Transactional;
import org.springframework.transaction.support.TransactionTemplate;
import org.springframework.web.server.ResponseStatusException; import org.springframework.web.server.ResponseStatusException;
import java.sql.Timestamp; import java.sql.Timestamp;
@@ -23,12 +27,21 @@ public class PlatformService {
private final AiClient aiClient; private final AiClient aiClient;
private final RuleEngine ruleEngine; private final RuleEngine ruleEngine;
private final KafkaTemplate<String, Object> kafkaTemplate; 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.jdbc = jdbc;
this.aiClient = aiClient; this.aiClient = aiClient;
this.ruleEngine = ruleEngine; this.ruleEngine = ruleEngine;
this.kafkaTemplate = kafkaTemplate; this.kafkaTemplate = kafkaTemplate;
this.transactionTemplate = new TransactionTemplate(transactionManager);
this.transactionTemplate.setPropagationBehavior(TransactionDefinition.PROPAGATION_REQUIRES_NEW);
} }
@Transactional @Transactional
@@ -150,12 +163,23 @@ public class PlatformService {
"""); """);
} }
@Transactional
public Map<String, Object> completeResources(Requests.ResourceCompleteRequest request) { public Map<String, Object> completeResources(Requests.ResourceCompleteRequest request) {
List<String> resourceIds = ingestResources(request); Map<String, Object> prepared = transactionTemplate.execute(ignored -> {
Map<String, Object> job = createAnalysisJob(new Requests.CreateAnalysisJobRequest(request.taskId(), resourceIds, null, "offline", "normal")); List<String> resourceIds = ingestResources(request);
runAnalysis(String.valueOf(job.get("analysis_job_id"))); Map<String, Object> job = createAnalysisJob(new Requests.CreateAnalysisJobRequest(
return Map.of("accepted", resourceIds.size(), "analysis_job_id", job.get("analysis_job_id")); 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 @Transactional
@@ -224,16 +248,77 @@ public class PlatformService {
"""); """);
} }
@Transactional
public Map<String, Object> runAnalysis(String jobId) { 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"))); 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> resourceIds = Jsonb.stringList(String.valueOf(job.get("resource_ids_text")));
List<String> scenes = Jsonb.stringList(String.valueOf(job.get("scene_set_text"))); List<String> scenes = Jsonb.stringList(String.valueOf(job.get("scene_set_text")));
int aiResults = 0; int aiResults = 0;
int alarms = 0; int alarms = 0;
int suppressed = 0; int suppressed = 0;
jdbc.update("update analysis_jobs set status='running' where id=?", jobId);
for (String resourceId : resourceIds) { for (String resourceId : resourceIds) {
Map<String, Object> resource = resourceById(resourceId); Map<String, Object> resource = resourceById(resourceId);
List<Map<String, Object>> results = aiClient.analyze(resource, scenes); 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); 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")); 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); return Map.of("analysis_job_id", jobId, "status", "completed", "summary", summary);
} }
public List<Map<String, Object>> listAlarms(boolean includeSuppressed) { private Map<String, Object> currentAnalysisResponse(String jobId, Map<String, Object> job) {
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"; Map<String, Object> response = new HashMap<>();
if (!includeSuppressed) { response.put("analysis_job_id", jobId);
sql += " where suppressed=false"; 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); return jdbc.queryForList(sql);
} }
@Transactional @Transactional
public Map<String, Object> decideAlarm(String alarmId, Requests.AlarmDecisionRequest request) { public Map<String, Object> decideAlarm(String alarmId, Requests.AlarmDecisionRequest request) {
String action = request.action() == null ? "confirm" : request.action(); 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; String status;
boolean suppressed; boolean suppressed;
String reason = request.reason(); String reason = request.reason();
@@ -277,11 +425,15 @@ public class PlatformService {
suppressed = true; suppressed = true;
} }
case "reopen" -> { case "reopen" -> {
status = "pending"; status = linkedWorkorders.isEmpty()
? "pending"
: WorkOrderStateMachine.alarmStatusFor(String.valueOf(linkedWorkorders.get(0).get("status")));
suppressed = false; suppressed = false;
} }
default -> { default -> {
status = "confirmed"; status = linkedWorkorders.isEmpty()
? "confirmed"
: WorkOrderStateMachine.alarmStatusFor(String.valueOf(linkedWorkorders.get(0).get("status")));
suppressed = false; 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 alarm_id=?", request.alarmId())
: jdbc.queryForMap("select id, alarm_id from work_orders where id=?", request.workorderId()); : jdbc.queryForMap("select id, alarm_id from work_orders where id=?", request.workorderId());
String workorderId = String.valueOf(workorder.get("id")); 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.status() == null ? "closed" : request.status(),
request.result(), request.result(),
request.comment(), request.comment(),
Timestamp.from(Instant.now()), Timestamp.from(Instant.now()),
request.alarmId()); workorderId);
String alarmStatus = "closed".equals(request.status()) || request.status() == null ? "closed" : "processing"; String alarmStatus = WorkOrderStateMachine.alarmStatusFor(
jdbc.update("update alarms set status=?, updated_at=? where id=?", alarmStatus, Timestamp.from(Instant.now()), request.alarmId()); 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()); insertWorkOrderOperation(workorderId, "status_callback", request.operator(), request.result(), request.comment());
publish("workorder.closed", Map.of("alarm_id", request.alarmId(), "result", request.result())); publish("workorder.closed", Map.of("alarm_id", alarmId, "result", request.result()));
return Map.of("workorder_id", workorderId, "alarm_id", request.alarmId(), "status", request.status() == null ? "closed" : request.status()); return Map.of("workorder_id", workorderId, "alarm_id", alarmId, "status", request.status() == null ? "closed" : request.status());
} }
@Transactional @Transactional
@@ -347,8 +503,8 @@ public class PlatformService {
Instant now = Instant.now(); Instant now = Instant.now();
jdbc.update("update work_orders set status=?, close_result=?, comment=?, updated_at=? where id=?", jdbc.update("update work_orders set status=?, close_result=?, comment=?, updated_at=? where id=?",
targetStatus, request.result(), request.comment(), Timestamp.from(now), workorderId); targetStatus, request.result(), request.comment(), Timestamp.from(now), workorderId);
jdbc.update("update alarms set status=?, updated_at=? where id=?", jdbc.update("update alarms set status=?,suppressed=false,suppression_reason=null,updated_at=? where id=?",
"closed".equals(targetStatus) ? "closed" : "processing", Timestamp.from(now), workorder.get("alarm_id")); WorkOrderStateMachine.alarmStatusFor(targetStatus), Timestamp.from(now), workorder.get("alarm_id"));
insertWorkOrderOperation(workorderId, "return".equals(action) ? "review_returned" : "review_approved", insertWorkOrderOperation(workorderId, "return".equals(action) ? "review_returned" : "review_approved",
request.operator(), request.result(), request.comment()); request.operator(), request.result(), request.comment());
publish("workorder.reviewed", Map.of("workorder_id", workorderId, "status", targetStatus)); publish("workorder.reviewed", Map.of("workorder_id", workorderId, "status", targetStatus));
@@ -358,11 +514,21 @@ public class PlatformService {
@Transactional @Transactional
public Map<String, Object> redispatchWorkOrder(String workorderId, String assignee, String reason, String operator) { public Map<String, Object> redispatchWorkOrder(String workorderId, String assignee, String reason, String operator) {
String resolvedAssignee = assignee == null || assignee.isBlank() ? "专业复核人员" : assignee; 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=?", int updated = jdbc.update("update work_orders set status='created', assignee=?, comment=?, updated_at=? where id=?",
resolvedAssignee, reason, Timestamp.from(Instant.now()), workorderId); resolvedAssignee, reason, Timestamp.from(Instant.now()), workorderId);
if (updated == 0) { if (updated == 0) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "工单不存在"); 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); insertWorkOrderOperation(workorderId, "redispatched", operator, "重新派发", reason);
publish("workorder.redispatched", Map.of("workorder_id", workorderId, "assignee", resolvedAssignee)); publish("workorder.redispatched", Map.of("workorder_id", workorderId, "assignee", resolvedAssignee));
return Map.of("workorder_id", workorderId, "status", "created", "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("evidence", Jsonb.map(String.valueOf(alarm.get("evidence"))));
alarm.put("rule_hits", Jsonb.stringList(String.valueOf(alarm.get("rule_hits")))); alarm.put("rule_hits", Jsonb.stringList(String.valueOf(alarm.get("rule_hits"))));
Map<String, Object> result = resultEvidence(String.valueOf(alarm.get("result_id"))); 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); return Map.of("alarm", alarm, "ai_result", result, "workorders", relatedWorkorders);
} }
@@ -541,7 +710,7 @@ public class PlatformService {
Jsonb.write(decision.location()), Jsonb.write(decision.location()),
Jsonb.write(Map.of("resource_id", resource.get("id"), "storage_url", resource.get("storage_url"))), Jsonb.write(Map.of("resource_id", resource.get("id"), "storage_url", resource.get("storage_url"))),
Jsonb.write(decision.ruleHits()), Jsonb.write(decision.ruleHits()),
decision.suppressed() ? "suppressed" : "pending", decision.suppressed() ? "suppressed" : "dispatched",
decision.suppressed(), decision.suppressed(),
decision.suppressionReason(), decision.suppressionReason(),
ownerOrgId, ownerOrgId,
@@ -27,6 +27,7 @@ spring:
properties: properties:
spring.json.trusted.packages: "*" spring.json.trusted.packages: "*"
spring.json.use.type.headers: false spring.json.use.type.headers: false
spring.json.value.default.type: java.util.LinkedHashMap
producer: producer:
key-serializer: org.apache.kafka.common.serialization.StringSerializer key-serializer: org.apache.kafka.common.serialization.StringSerializer
value-serializer: org.springframework.kafka.support.serializer.JsonSerializer value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
@@ -36,7 +36,24 @@ class MissionStateMachineTest {
void taskStatusFollowsMissionLifecycle() { void taskStatusFollowsMissionLifecycle() {
assertThat(InspectionTaskWorkflow.taskStatusForMission("DISPATCHED")).isEqualTo("dispatched"); assertThat(InspectionTaskWorkflow.taskStatusForMission("DISPATCHED")).isEqualTo("dispatched");
assertThat(InspectionTaskWorkflow.taskStatusForMission("PAUSED")).isEqualTo("flying"); 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"); 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);
}
} }
@@ -22,4 +22,12 @@ class WorkOrderStateMachineTest {
assertThat(WorkOrderStateMachine.canApply("closed", "RETURN")).isFalse(); assertThat(WorkOrderStateMachine.canApply("closed", "RETURN")).isFalse();
assertThat(WorkOrderStateMachine.supports("UNKNOWN")).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");
}
} }
@@ -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;
}
}
}
@@ -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);
}
}
}
@@ -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);
}
}
@@ -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) {
}
}
}
+22 -2
View File
@@ -1,6 +1,6 @@
$ErrorActionPreference = "Stop" $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" $apiUrl = "$baseUrl/api/v1"
$runKey = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmssfff") $runKey = (Get-Date).ToUniversalTime().ToString("yyyyMMddHHmmssfff")
$tempPng = Join-Path ([IO.Path]::GetTempPath()) "rail-capability-$runKey.png" $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 { try {
Write-Host "Checking capability registry and spatial objects..." Write-Host "Checking capability registry and spatial objects..."
$completion = Invoke-RailApi -Method GET -Path "/capabilities/completion" $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" $validation = Invoke-RailApi -Method POST -Path "/route-versions/$($route.route_version_id)/validate"
if (-not $validation.valid) { throw "Route validation failed" } 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" $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" } if ($published.status -ne "PUBLISHED") { throw "Route publication failed" }
@@ -108,7 +124,8 @@ try {
$plan = Invoke-RailApi -Method POST -Path "/inspection/plans" -Body @{ $plan = Invoke-RailApi -Method POST -Path "/inspection/plans" -Body @{
name = "Capability regression plan $runKey" name = "Capability regression plan $runKey"
plan_type = "SPECIAL" plan_type = "SPECIAL"
schedule_rule = "MANUAL" trigger_type = "MANUAL"
schedule_config = @{ timing = "NOW" }
object_ids = @($object.object_id) object_ids = @($object.object_id)
scene_set = @("FOREIGN_OBJECT", "FENCE_DAMAGE") scene_set = @("FOREIGN_OBJECT", "FENCE_DAMAGE")
priority = "high" priority = "high"
@@ -120,6 +137,9 @@ try {
$generated = Invoke-RailApi -Method POST -Path "/inspection/plans/$($plan.plan_id)/generate-tasks" $generated = Invoke-RailApi -Method POST -Path "/inspection/plans/$($plan.plan_id)/generate-tasks"
$taskId = @($generated.task_ids)[0] $taskId = @($generated.task_ids)[0]
if (-not $taskId) { throw "Plan did not generate an inspection task" } 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..." Write-Host "Checking simulator connection and executing a flight mission..."
$connections = Invoke-RailApi -Method GET -Path "/uav/connections" $connections = Invoke-RailApi -Method GET -Path "/uav/connections"
+38 -4
View File
@@ -1,6 +1,6 @@
$ErrorActionPreference = "Stop" $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" $apiUrl = "$baseUrl/api/v1"
Write-Host "Checking navigation routes..." 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 $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" } 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" } if ($cancel.data.status -ne "cancelled") { throw "Task cancellation failed" }
Write-Host "Checking alarm decision operation..." Write-Host "Checking alarm decision operation..."
@@ -96,15 +124,21 @@ $alarmId = @($alarmsResponse.data.alarms | Where-Object { $_.status -ne "closed"
if ($alarmId) { if ($alarmId) {
$confirmBody = @{ action = "confirm"; reason = "页面操作回归"; operator = "测试人员" } | ConvertTo-Json $confirmBody = @{ action = "confirm"; reason = "页面操作回归"; operator = "测试人员" } | ConvertTo-Json
$confirmed = Invoke-RestMethod -Method Post -Uri "$apiUrl/alarms/$alarmId/decision" -ContentType "application/json" -Body $confirmBody -TimeoutSec 30 $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]@{ $result = [ordered]@{
route_count = $routes.Count route_count = $routes.Count
endpoint_count = $endpointChecks.Count endpoint_count = $endpointChecks.Count
created_task_id = $taskId 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_checked = [bool]$alarmId
alarm_decision_status = if ($alarmId) { $confirmed.data.status } else { $null }
endpoints = $summary endpoints = $summary
} }
$result | ConvertTo-Json -Depth 5 $result | ConvertTo-Json -Depth 5
+2 -2
View File
@@ -20,8 +20,8 @@ if (-not $spatial.open3d_available) {
$models = @($vision.models) + @($spatial.models) $models = @($vision.models) + @($spatial.models)
$groups = @($models | Select-Object -ExpandProperty model_group -Unique) $groups = @($models | Select-Object -ExpandProperty model_group -Unique)
$expected = @("vision-detector", "vision-segmenter", "change-detector", "pointcloud-analyzer", "thermal-analyzer") $expected = @("vision-detector", "vision-segmenter", "change-detector", "pointcloud-analyzer", "thermal-analyzer")
if ($models.Count -ne 5) { if ($groups.Count -ne 5) {
throw "CPU runtime must expose exactly five model groups, received $($models.Count)." throw "CPU runtime must expose exactly five model groups, received $($groups.Count)."
} }
foreach ($group in $expected) { foreach ($group in $expected) {
if ($group -notin $groups) { if ($group -notin $groups) {
@@ -7,9 +7,11 @@ spring:
jackson: jackson:
property-naming-strategy: SNAKE_CASE property-naming-strategy: SNAKE_CASE
datasource: 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} username: ${SPRING_DATASOURCE_USERNAME:rail}
password: ${SPRING_DATASOURCE_PASSWORD:rail} password: ${SPRING_DATASOURCE_PASSWORD:rail}
hikari:
schema: ${SPRING_DATASOURCE_SCHEMA:uav_access}
flyway: flyway:
enabled: true enabled: true
create-schemas: true create-schemas: true
+2
View File
@@ -0,0 +1,2 @@
outputs/*
!outputs/.gitkeep
+1
View File
@@ -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