Implement AI Host functionality with model caching and job management

- Added ai_host.py to handle AI tasks with FastAPI integration.
- Introduced HostState and ModelCache classes for managing client connections and model loading.
- Implemented job submission and cancellation endpoints in the AI host.
- Enhanced tests for AI host functionality, including client tracking and job cancellation.
- Updated existing tests to accommodate changes in the operator and command structure.
- Refactored runtime commands to use request-based architecture instead of command-line arguments.
- Improved model loading logic to handle CUDA memory management and reuse models efficiently.
This commit is contained in:
2026-08-04 07:35:40 +08:00
parent d694980da7
commit 838af0ee96
16 changed files with 2112 additions and 225 deletions

View File

@@ -23,10 +23,27 @@ SCENE_FILENAME = "scene.glb"
EXPORT_FORMATS = ("glb", "gs_ply", "depth_vis", "mini_npz")
def write_status(path, progress, message, error=None):
class TaskCancelled(RuntimeError):
pass
def check_cancelled(cancel_check):
if cancel_check is not None and cancel_check():
raise TaskCancelled("Task cancelled")
def write_status(path, progress, message, error=None, state=None):
payload = {"progress": progress, "message": message}
if error:
payload["error"] = error
if state is None:
if error:
state = "failed"
elif progress >= 1.0:
state = "succeeded"
else:
state = "running"
payload["state"] = state
Path(path).write_text(
json.dumps(payload, ensure_ascii=False),
encoding="utf-8",
@@ -42,7 +59,13 @@ def collect_images(directory):
)
def extract_video_frames(video_path, output_directory, maximum_frames, cv2):
def extract_video_frames(
video_path,
output_directory,
maximum_frames,
cv2,
cancel_check=None,
):
capture = cv2.VideoCapture(str(video_path))
if not capture.isOpened():
raise RuntimeError(f"Unable to open video: {video_path}")
@@ -66,6 +89,7 @@ def extract_video_frames(video_path, output_directory, maximum_frames, cv2):
image_paths = []
try:
for output_index, frame_index in enumerate(frame_indices):
check_cancelled(cancel_check)
capture.set(cv2.CAP_PROP_POS_FRAMES, int(frame_index))
success, frame = capture.read()
if not success:
@@ -82,7 +106,8 @@ def extract_video_frames(video_path, output_directory, maximum_frames, cv2):
return image_paths
def prepare_input(input_path, video_frames, temporary_root):
def prepare_input(input_path, video_frames, temporary_root, cancel_check=None):
check_cancelled(cancel_check)
input_path = Path(input_path)
suffix = input_path.suffix.lower()
if suffix in IMAGE_SUFFIXES:
@@ -102,6 +127,7 @@ def prepare_input(input_path, video_frames, temporary_root):
temporary_directory,
video_frames,
cv2,
cancel_check=cancel_check,
)
except Exception:
shutil.rmtree(temporary_directory, ignore_errors=True)
@@ -203,19 +229,33 @@ def select_device(requested, torch):
return torch.device("cuda" if torch.cuda.is_available() else "cpu")
def infer_foreground(input_path, model_dir, device, torch):
from ben2 import BEN_Base
def infer_foreground(
input_path,
model_dir,
device,
torch,
model_cache=None,
cancel_check=None,
):
from PIL import Image
input_path = Path(input_path)
check_cancelled(cancel_check)
if input_path.suffix.lower() not in IMAGE_SUFFIXES:
raise RuntimeError("BEN2 requires a JPG, JPEG, PNG, or WEBP image")
load_started = time.perf_counter()
model = BEN_Base.from_pretrained(model_dir)
model = model.to(device=device)
model.eval()
load_ms = (time.perf_counter() - load_started) * 1000.0
owns_model = model_cache is None
if owns_model:
from ben2 import BEN_Base
load_started = time.perf_counter()
model = BEN_Base.from_pretrained(model_dir)
model = model.to(device=device)
model.eval()
load_ms = (time.perf_counter() - load_started) * 1000.0
else:
model, load_ms = model_cache.get_ben2(model_dir, device)
check_cancelled(cancel_check)
with Image.open(input_path) as opened_image:
image = opened_image.convert("RGB")
if device.type == "cuda":
@@ -229,17 +269,23 @@ def infer_foreground(input_path, model_dir, device, torch):
if device.type == "cuda":
torch.cuda.synchronize()
inference_ms = (time.perf_counter() - inference_started) * 1000.0
del model
if device.type == "cuda":
torch.cuda.empty_cache()
if owns_model:
del model
if device.type == "cuda":
torch.cuda.empty_cache()
check_cancelled(cancel_check)
return foreground, load_ms, inference_ms
def remove_background(args):
def remove_background(args, model_cache=None, cancel_check=None):
total_started = time.perf_counter()
write_status(args.status, 0.02, "Starting BEN2 Runtime")
check_cancelled(cancel_check)
import torch
if model_cache is None:
import torch
else:
torch = model_cache.torch
output_directory = Path(args.output_dir)
output_directory.mkdir(parents=True, exist_ok=False)
@@ -252,8 +298,11 @@ def remove_background(args):
args.model_dir,
device,
torch,
model_cache=model_cache,
cancel_check=cancel_check,
)
check_cancelled(cancel_check)
write_status(args.status, 0.92, "Writing transparent PNG")
output_path = output_directory / "foreground.png"
foreground.convert("RGBA").save(
@@ -264,6 +313,7 @@ def remove_background(args):
if not output_path.is_file():
raise RuntimeError("BEN2 did not write a foreground image")
check_cancelled(cancel_check)
total_ms = (time.perf_counter() - total_started) * 1000.0
message = (
f"Background removed in {inference_ms:.0f} ms "
@@ -288,10 +338,16 @@ def exported_paths(output_directory, export_format):
return []
def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
def wait_for_exported_paths(
output_directory,
export_format,
timeout=30.0,
cancel_check=None,
):
# 上游 NPZ exporter 使用后台线程,因此需要等待文件实际落盘。
deadline = time.monotonic() + timeout
while time.monotonic() < deadline:
check_cancelled(cancel_check)
result_paths = exported_paths(output_directory, export_format)
if result_paths:
return result_paths
@@ -299,11 +355,15 @@ def wait_for_exported_paths(output_directory, export_format, timeout=30.0):
return []
def reconstruct(args):
def reconstruct(args, model_cache=None, cancel_check=None):
write_status(args.status, 0.02, "Starting AI runtime")
check_cancelled(cancel_check)
import torch
from depth_anything_3.api import DepthAnything3
if model_cache is None:
import torch
from depth_anything_3.api import DepthAnything3
else:
torch = model_cache.torch
from depth_anything_3.utils.export import export
output_directory = Path(args.output_dir)
@@ -313,9 +373,11 @@ def reconstruct(args):
args.input,
args.video_frames,
output_directory.parent,
cancel_check=cancel_check,
)
try:
check_cancelled(cancel_check)
device = select_device(args.device, torch)
remove_background_model_dir = getattr(
args,
@@ -329,7 +391,10 @@ def reconstruct(args):
remove_background_model_dir,
device,
torch,
model_cache=model_cache,
cancel_check=cancel_check,
)
check_cancelled(cancel_check)
foreground.convert("RGBA").save(
output_directory / "foreground.png",
format="PNG",
@@ -342,10 +407,14 @@ def reconstruct(args):
f"model load {load_ms:.0f} ms"
)
write_status(args.status, 0.15, f"Loading model on {device.type}")
model = DepthAnything3.from_pretrained(args.model_dir)
model = model.to(device=device)
model.eval()
if model_cache is None:
model = DepthAnything3.from_pretrained(args.model_dir)
model = model.to(device=device)
model.eval()
else:
model = model_cache.get_da3(args.model_dir, device)
check_cancelled(cancel_check)
write_status(
args.status,
0.25,
@@ -372,6 +441,7 @@ def reconstruct(args):
use_ray_pose=bool(args.use_ray_pose),
ref_view_strategy=args.ref_view_strategy,
)
check_cancelled(cancel_check)
write_status(args.status, 0.88, f"Exporting {args.export_format}")
export(
prediction,
@@ -379,15 +449,18 @@ def reconstruct(args):
str(output_directory),
**export_kwargs,
)
check_cancelled(cancel_check)
result_paths = wait_for_exported_paths(
output_directory,
args.export_format,
cancel_check=cancel_check,
)
if not result_paths:
raise RuntimeError(
f"No {args.export_format} files were exported"
)
check_cancelled(cancel_check)
write_status(
args.status,
1.0,