Add comprehensive tests for cutout and mesh functionalities

- Introduced `test_cutout.py` to validate cutout pie menu behavior, image lasso options, and mesh detail calculations.
- Added `test_mesh.py` to ensure correct tangent normal rasterization, plane source topology, and depth surface evaluations.
- Created `test_mesh_blender.py` to verify material node groups, tangent baking, and local cutout building from Blender images.
- Implemented tests for handling alpha regions and ensuring geometry nodes maintain expected behavior.
This commit is contained in:
2026-08-26 10:42:29 +08:00
parent 0cf04a13c6
commit 2b79a342b0
46 changed files with 5554 additions and 645 deletions

View File

@@ -2,16 +2,14 @@
from blendjob import JobServer
from .jobs import generate_depth as _generate_depth
from .jobs import generate_surface as _generate_surface
from .jobs.background import run as _remove_background
from .jobs.balloon import run as _analyze_balloon
from .jobs.debug_ben2 import run as _debug_ben2
from .jobs.debug_da3 import run as _debug_da3
from .jobs.debug_moge2 import run as _debug_moge2
from .jobs.debug_upscale import run as _debug_upscale
from .jobs.frames import run as _extract_frames
from .jobs.image_mask import run as _refine_image_mask
from .jobs.mesh import run as _generate_mesh
from .jobs.upscale import run as _upscale_image
from .model_manager import ModelManager
@@ -36,11 +34,6 @@ def download_required_models(context, parameters):
return manager.download_missing(context, parameters["models"])
@server.job("analyze-balloon")
def analyze_balloon(context, parameters):
return _analyze_balloon(context, parameters)
@server.job("refine-image-mask")
def refine_image_mask(context, parameters):
return _refine_image_mask(context, parameters)
@@ -61,14 +54,9 @@ def extract_frames(context, parameters):
return _extract_frames(context, parameters)
@server.job("generate-depth")
def generate_depth(context, parameters):
return _generate_depth(context, parameters)
@server.job("generate-surface")
def generate_surface(context, parameters):
return _generate_surface(context, parameters)
@server.job("generate-mesh")
def generate_mesh(context, parameters):
return _generate_mesh(context, parameters)
@server.job("debug-da3")

View File

@@ -0,0 +1,211 @@
def _choose_boundary_edge(start, direction, candidates):
right = (-direction[1], direction[0])
left = (direction[1], -direction[0])
back = (-direction[0], -direction[1])
preference = {right: 0, direction: 1, left: 2, back: 3}
return min(
candidates,
key=lambda end: preference.get(
(end[0] - start[0], end[1] - start[1]),
4,
),
)
def _simplify_axis_aligned_contour(points):
simplified = []
for index, current in enumerate(points):
previous = points[index - 1]
following = points[(index + 1) % len(points)]
first = (current[0] - previous[0], current[1] - previous[1])
second = (following[0] - current[0], following[1] - current[1])
if first[0] * second[1] - first[1] * second[0] != 0:
simplified.append(current)
return simplified
def trace_mask_contours(mask):
import numpy as np
mask = np.asarray(mask, dtype=bool)
if mask.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
height, width = mask.shape
edges = {}
def add_edge(start, end):
edges.setdefault(start, []).append(end)
top = mask.copy()
top[1:] &= ~mask[:-1]
for y, x in np.argwhere(top):
add_edge((int(x), int(y)), (int(x) + 1, int(y)))
right = mask.copy()
right[:, :-1] &= ~mask[:, 1:]
for y, x in np.argwhere(right):
add_edge((int(x) + 1, int(y)), (int(x) + 1, int(y) + 1))
bottom = mask.copy()
bottom[:-1] &= ~mask[1:]
for y, x in np.argwhere(bottom):
add_edge((int(x) + 1, int(y) + 1), (int(x), int(y) + 1))
left = mask.copy()
left[:, 1:] &= ~mask[:, :-1]
for y, x in np.argwhere(left):
add_edge((int(x), int(y) + 1), (int(x), int(y)))
if not edges:
raise ValueError("The Cutout region has no usable boundary")
contours = []
edge_count = sum(len(values) for values in edges.values())
while edges:
start = min(edges, key=lambda point: (point[1], point[0]))
current = start
direction = (1, 0)
contour = []
for _index in range(edge_count + 1):
contour.append(current)
candidates = edges.get(current)
if not candidates:
raise ValueError("The Cutout boundary is open")
following = _choose_boundary_edge(current, direction, candidates)
candidates.remove(following)
if not candidates:
del edges[current]
direction = (following[0] - current[0], following[1] - current[1])
current = following
if current == start:
break
else:
raise ValueError("The Cutout boundary could not be closed")
contour = _simplify_axis_aligned_contour(contour)
if len(contour) >= 3:
contours.append(contour)
if not contours:
raise ValueError("The Cutout region is too small")
return contours
def _signed_contour_area(contour):
return 0.5 * sum(
start[0] * end[1] - end[0] * start[1]
for start, end in zip(contour, contour[1:] + contour[:1])
)
def _point_inside_contour(point, contour):
inside = False
previous = contour[-1]
for current in contour:
if (current[1] > point[1]) != (previous[1] > point[1]):
crossing_x = current[0] + (
(point[1] - current[1])
* (previous[0] - current[0])
/ (previous[1] - current[1])
)
inside ^= point[0] < crossing_x
previous = current
return inside
def mask_regions(mask):
contours = trace_mask_contours(mask)
outer = [contour for contour in contours if _signed_contour_area(contour) > 0.0]
holes = [contour for contour in contours if _signed_contour_area(contour) < 0.0]
if not outer:
raise ValueError("The Cutout region has no outer boundary")
regions = [[contour] for contour in outer]
outer_areas = [abs(_signed_contour_area(contour)) for contour in outer]
for hole in holes:
start = hole[0]
end = hole[1]
edge_x = end[0] - start[0]
edge_y = end[1] - start[1]
length = max((edge_x * edge_x + edge_y * edge_y) ** 0.5, 1e-12)
sample = (
(start[0] + end[0]) * 0.5 - edge_y * 0.25 / length,
(start[1] + end[1]) * 0.5 + edge_x * 0.25 / length,
)
owners = [
index
for index, contour in enumerate(outer)
if _point_inside_contour(sample, contour)
]
if not owners:
raise ValueError("The Cutout hole has no enclosing region")
owner = min(owners, key=outer_areas.__getitem__)
regions[owner].append(hole)
return regions
def _resize_alpha(values, size):
import numpy as np
target_width, target_height = (int(value) for value in size)
source_height, source_width = values.shape
if (target_width, target_height) == (source_width, source_height):
return values
x = (np.arange(target_width, dtype=np.float32) + 0.5) * (
source_width / target_width
) - 0.5
y = (np.arange(target_height, dtype=np.float32) + 0.5) * (
source_height / target_height
) - 0.5
x0_unclipped = np.floor(x).astype(np.int32)
y0_unclipped = np.floor(y).astype(np.int32)
x_weight = x - x0_unclipped
y_weight = y - y0_unclipped
x0 = np.clip(x0_unclipped, 0, source_width - 1)
y0 = np.clip(y0_unclipped, 0, source_height - 1)
x1 = np.clip(x0_unclipped + 1, 0, source_width - 1)
y1 = np.clip(y0_unclipped + 1, 0, source_height - 1)
horizontal = (
values[:, x0] * (1.0 - x_weight)[None, :]
+ values[:, x1] * x_weight[None, :]
)
return (
horizontal[y0] * (1.0 - y_weight)[:, None]
+ horizontal[y1] * y_weight[:, None]
)
def alpha_regions(alpha, max_resolution=512, threshold=0.5):
import numpy as np
values = np.asarray(alpha)
if values.ndim != 2:
raise ValueError("Cutout Alpha must be two-dimensional")
if np.issubdtype(values.dtype, np.floating):
values = np.clip(values, 0.0, 1.0) * 255.0
else:
values = np.clip(values, 0, 255).astype(np.float32)
source_size = (values.shape[1], values.shape[0])
scale = min(1.0, float(max_resolution) / max(source_size))
sampled_size = tuple(max(1, round(value * scale)) for value in source_size)
if sampled_size != source_size:
values = _resize_alpha(values, sampled_size)
mask = values > round(255.0 * threshold)
regions = mask_regions(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]
return (
[
[
[[float(x) * scale_x, float(y) * scale_y] for x, y in contour]
for contour in region
]
for region in regions
],
source_size,
)
def image_alpha_regions(path, max_resolution=512, threshold=0.5):
import numpy as np
from PIL import Image
with Image.open(path) as source:
alpha = np.asarray(source.convert("RGBA").getchannel("A"), dtype=np.uint8)
return alpha_regions(alpha, max_resolution=max_resolution, threshold=threshold)

View File

@@ -1,5 +1 @@
from .depth import run as generate_depth
from .surface import run as generate_surface
__all__ = ("generate_depth", "generate_surface")
"""Job implementations registered by :mod:`anyimage.server.app`."""

View File

@@ -5,15 +5,18 @@ from pathlib import Path
from ..models import moge2
from ..models.ben2 import infer_foreground
from .image_mask import polygon_selection_mask
ALPHA_THRESHOLD = 0.5
def create_lasso_input(input_path, polygon, output_path):
def create_lasso_input(
input_path, polygon, output_path, fill_rule="EVEN_ODD"
):
"""Crop a lasso and clear every RGBA channel outside its polygon."""
import numpy as np
from PIL import Image, ImageDraw
from PIL import Image
points = np.asarray(polygon, dtype=np.float64)
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
@@ -33,12 +36,7 @@ def create_lasso_input(input_path, polygon, output_path):
relative = points - np.asarray((left, top), dtype=np.float64)
cropped = source.crop((left, top, right, bottom))
selection = Image.new("L", cropped.size, 0)
ImageDraw.Draw(selection).polygon(
[tuple(point) for point in relative],
fill=255,
)
selection_mask = np.asarray(selection, dtype=np.uint8) > 0
selection_mask = polygon_selection_mask(cropped.size, relative, fill_rule)
pixels = np.asarray(cropped, dtype=np.uint8).copy()
pixels[~selection_mask] = 0
masked = Image.fromarray(pixels, mode="RGBA")
@@ -285,7 +283,7 @@ def write_depth_analysis(
return Path(output_path)
def write_balloon_normal_texture(
def write_camera_normal_texture(
normal,
valid,
origin,
@@ -293,7 +291,7 @@ def write_balloon_normal_texture(
source_size,
output_path,
):
"""Write MoGe camera normals as a full-image tangent-space texture."""
"""Encode MoGe camera-axis normals into a full-image RGB texture."""
import numpy as np
from PIL import Image
@@ -412,7 +410,7 @@ def run(context, parameters):
import numpy as np
with np.load(depth_path, allow_pickle=False) as data:
normal_path = write_balloon_normal_texture(
normal_path = write_camera_normal_texture(
prediction.normal[0],
data["valid"],
origin,

View File

@@ -24,3 +24,14 @@ def input_frames(context, parameters):
finally:
if temporary_directory is not None:
shutil.rmtree(temporary_directory, ignore_errors=True)
@contextmanager
def analysis_frames(context, parameters, fallback_images):
analysis_input = parameters.get("analysis_input")
if not analysis_input or analysis_input == parameters["input"]:
yield fallback_images
return
analysis_parameters = dict(parameters, input=analysis_input)
with input_frames(context, analysis_parameters) as images:
yield images

View File

@@ -1,7 +1,7 @@
from ..media import copy_color_images
from ..models import da3, geometry_adapter, moge2
from ..outputs.depth import write_da3_depth, write_moge_depth
from .common import frame_progress_message, input_frames
from .common import analysis_frames, frame_progress_message, input_frames
from .foreground import write_optional
@@ -11,31 +11,32 @@ def run(context, parameters):
model_manager = context.resource("model_manager")
adapter = geometry_adapter(parameters["model"])
with input_frames(context, parameters) as images:
if adapter is da3:
prediction = adapter.infer(
model_manager,
parameters,
images,
progress_callback=lambda index, total: context.progress(
0.15 + 0.65 * index / total,
frame_progress_message("Estimating DA3 depth", index, total),
),
cancel_check=context.check_cancelled,
)
write_da3_depth(prediction, context.directory / "results.npz")
else:
prediction = adapter.infer(
model_manager,
parameters,
images,
include_points=False,
progress_callback=lambda index, total: context.progress(
0.15 + 0.65 * index / total,
frame_progress_message("Estimating MoGe-2 depth", index, total),
),
cancel_check=context.check_cancelled,
)
write_moge_depth(context.directory, prediction)
with analysis_frames(context, parameters, images) as model_images:
if adapter is da3:
prediction = adapter.infer(
model_manager,
parameters,
model_images,
progress_callback=lambda index, total: context.progress(
0.15 + 0.65 * index / total,
frame_progress_message("Estimating DA3 depth", index, total),
),
cancel_check=context.check_cancelled,
)
write_da3_depth(prediction, context.directory / "results.npz")
else:
prediction = adapter.infer(
model_manager,
parameters,
model_images,
include_points=False,
progress_callback=lambda index, total: context.progress(
0.15 + 0.65 * index / total,
frame_progress_message("Estimating MoGe-2 depth", index, total),
),
cancel_check=context.check_cancelled,
)
write_moge_depth(context.directory, prediction)
color_paths = write_optional(
context,
parameters,

View File

@@ -22,10 +22,68 @@ def polygon_bounds(polygon):
return left, top, right, bottom
def create_selection_canvas(input_path, polygon):
def polygon_selection_mask(image_size, polygon, fill_rule="EVEN_ODD"):
"""Rasterize a polygon with an explicit fill rule."""
import numpy as np
width, height = (int(value) for value in image_size)
points = np.asarray(polygon, dtype=np.float64)
if width <= 0 or height <= 0:
raise ValueError("The selection canvas has no readable dimensions")
if points.ndim != 2 or points.shape[0] < 3 or points.shape[1] != 2:
raise ValueError("The selection does not define a valid image region")
if not np.isfinite(points).all():
raise ValueError("The selection contains invalid image coordinates")
if fill_rule not in {"EVEN_ODD", "NONZERO"}:
raise ValueError(f"Unknown polygon fill rule: {fill_rule}")
scanlines = np.arange(height, dtype=np.float64) + 0.5
crossings = [[] for _index in range(height)]
for start, end in zip(points, np.roll(points, -1, axis=0)):
selected = np.flatnonzero(
(start[1] > scanlines) != (end[1] > scanlines)
)
if not len(selected):
continue
crossing_x = start[0] + (
(scanlines[selected] - start[1])
* (end[0] - start[0])
/ (end[1] - start[1])
)
winding = 1 if end[1] > start[1] else -1
for row, value in zip(selected, crossing_x):
crossings[int(row)].append((float(value), winding))
mask = np.zeros((height, width), dtype=bool)
for row, values in enumerate(crossings):
values.sort()
state = 0
index = 0
while index < len(values):
crossing_x = values[index][0]
winding = 0
count = 0
while index < len(values) and values[index][0] == crossing_x:
winding += values[index][1]
count += 1
index += 1
state = (
state + winding
if fill_rule == "NONZERO"
else state ^ (count % 2)
)
if index >= len(values) or state == 0:
continue
following_x = values[index][0]
first = max(0, int(math.ceil(crossing_x - 0.5)))
last = min(width, int(math.floor(following_x - 0.5)) + 1)
mask[row, first:last] = True
return mask
def create_selection_canvas(input_path, polygon, fill_rule="EVEN_ODD"):
"""Create an unbounded selection canvas with transparent black outside."""
import numpy as np
from PIL import Image, ImageDraw
from PIL import Image
with Image.open(input_path) as opened_image:
source = opened_image.convert("RGBA")
@@ -35,9 +93,7 @@ def create_selection_canvas(input_path, polygon):
canvas = Image.new("RGBA", (right - left, bottom - top), (0, 0, 0, 0))
canvas.paste(source, (-left, -top))
relative = [(float(x) - left, float(y) - top) for x, y in polygon]
selection = Image.new("L", canvas.size, 0)
ImageDraw.Draw(selection).polygon(relative, fill=255)
selection_mask = np.asarray(selection, dtype=np.uint8) > 0
selection_mask = polygon_selection_mask(canvas.size, relative, fill_rule)
pixels = np.asarray(canvas, dtype=np.uint8).copy()
pixels[~selection_mask] = 0
return Image.fromarray(pixels, mode="RGBA"), selection_mask, (

View File

@@ -0,0 +1,303 @@
from ..media import copy_color_images
from ..models import geometry_adapter, moge2
from .background import remove_image_background
from .balloon import (
write_camera_normal_texture,
write_depth_analysis,
)
from .common import analysis_frames, input_frames
from .image_mask import (
apply_selection_mask,
create_selection_canvas,
polygon_selection_mask,
)
def _write_field(frame, output_path):
import numpy as np
depth = np.asarray(frame.depth, dtype=np.float32)
points = np.asarray(frame.points, dtype=np.float32)
intrinsics = np.asarray(frame.intrinsics, dtype=np.float32)
valid = np.asarray(frame.valid_mask, dtype=bool)
if depth.ndim != 2 or points.shape != (*depth.shape, 3):
raise ValueError("The depth model returned an invalid point field")
valid &= np.isfinite(depth) & (depth > 0.0)
valid &= np.isfinite(points).all(axis=-1)
if not valid.any():
raise ValueError("The depth model returned no valid geometry")
reference_values = depth[valid]
if frame.model_mask is not None:
model_mask = np.asarray(frame.model_mask, dtype=bool)
masked = depth[valid & model_mask]
if len(masked):
reference_values = masked
np.savez_compressed(
output_path,
points=points,
depth=depth,
intrinsics=intrinsics,
valid=valid,
image_size=np.asarray((depth.shape[1], depth.shape[0]), dtype=np.int32),
reference_depth=np.asarray(np.median(reference_values), dtype=np.float32),
)
def _write_masked_color(input_path, polygon, output_path, invert=False):
import numpy as np
from PIL import Image, ImageChops, ImageFilter, ImageOps
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
selection = Image.fromarray(
polygon_selection_mask(source.size, polygon, "NONZERO").astype(np.uint8) * 255,
mode="L",
).filter(ImageFilter.GaussianBlur(radius=0.75))
if invert:
selection = ImageOps.invert(selection)
alpha = ImageChops.multiply(source.getchannel("A"), selection)
pixels = np.asarray(source, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(alpha, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode="RGBA").save(
output_path,
format="PNG",
compress_level=1,
)
return output_path
def _write_refined_color(context, parameters, input_path, polygon, output_path):
from PIL import Image, ImageChops
canvas, selection_mask, bounds = create_selection_canvas(
input_path, polygon, "NONZERO"
)
selection_path = context.directory / "selection-input.png"
canvas.save(selection_path, format="PNG", compress_level=1)
model_manager = context.resource("model_manager")
foreground, _load_ms, _inference_ms = remove_image_background(
selection_path,
model_manager.directory(parameters["refine_model"]),
parameters["device"],
model_manager,
context.check_cancelled,
)
refined = apply_selection_mask(foreground, selection_mask)
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
if parameters.get("invert"):
mask = Image.new("L", source.size, 0)
mask.paste(refined.getchannel("A"), bounds[:2])
source.putalpha(
ImageChops.multiply(source.getchannel("A"), ImageChops.invert(mask))
)
output = source
else:
output = Image.new("RGBA", source.size, (0, 0, 0, 0))
output.paste(refined, bounds[:2])
output.save(output_path, format="PNG", compress_level=1)
return output_path
def _write_camera_normal_from_prediction(prediction, color_path, output_path):
import numpy as np
from PIL import Image
normal = np.asarray(prediction.normal[0], dtype=np.float32)
with Image.open(color_path) as source:
alpha = source.convert("RGBA").getchannel("A")
source_size = source.size
valid = (
np.asarray(
alpha.resize((normal.shape[1], normal.shape[0]), Image.Resampling.BILINEAR)
)
> 127
)
return write_camera_normal_texture(
normal,
valid,
(0, 0),
source_size,
source_size,
output_path,
)
def _write_camera_normal(context, parameters, color_path):
normal_parameters = dict(parameters)
normal_parameters.update(
model=parameters["normal_model"],
resolution_level=parameters["normal_resolution_level"],
)
prediction = moge2.infer(
context.resource("model_manager"),
normal_parameters,
[color_path],
include_points=False,
cancel_check=context.check_cancelled,
)
return _write_camera_normal_from_prediction(
prediction,
color_path,
context.directory / "normal.png",
)
def _write_balloon_depth(
context,
parameters,
input_path,
polygon,
selection_size=None,
include_normal=False,
):
from PIL import Image
with Image.open(input_path) as source:
source_size = source.size
selection_size = selection_size or source_size
scale_x = source_size[0] / selection_size[0]
scale_y = source_size[1] / selection_size[1]
analysis_polygon = tuple(
(point[0] * scale_x, point[1] * scale_y) for point in polygon
)
selection_mask = polygon_selection_mask(
source_size,
analysis_polygon,
"NONZERO",
)
prediction = moge2.infer(
context.resource("model_manager"),
parameters,
[input_path],
include_points=False,
cancel_check=context.check_cancelled,
)
depth_path = write_depth_analysis(
prediction,
selection_mask,
(0, 0),
source_size,
context.directory / "depth.npz",
)
normal_path = None
if include_normal:
normal_path = _write_camera_normal_from_prediction(
prediction,
input_path,
context.directory / "normal.png",
)
return depth_path, normal_path
def _write_surface_depth(
context, parameters, color_path, include_normal=False
):
model_manager = context.resource("model_manager")
adapter = geometry_adapter(parameters["model"])
if adapter is moge2:
prediction = adapter.infer(
model_manager,
parameters,
[color_path],
include_points=True,
cancel_check=context.check_cancelled,
)
else:
prediction = adapter.infer(
model_manager,
parameters,
[color_path],
cancel_check=context.check_cancelled,
)
context.check_cancelled()
field_path = context.directory / "field.npz"
_write_field(adapter.surface_frame(prediction), field_path)
normal_path = None
if include_normal and adapter is moge2:
normal_path = _write_camera_normal_from_prediction(
prediction, color_path, context.directory / "normal.png"
)
return field_path, normal_path
def run(context, parameters):
context.progress(0.02, "Preparing mesh input")
context.directory.mkdir(parents=True, exist_ok=True)
with input_frames(context, parameters) as images:
if len(images) != 1:
raise ValueError("Empty Image to Mesh currently supports one image")
shape = parameters["shape"]
polygon = parameters.get("polygon")
if polygon:
if parameters.get("refine_selection"):
color_path = _write_refined_color(
context,
parameters,
images[0],
polygon,
context.directory / "image.0001.png",
)
else:
color_path = _write_masked_color(
images[0],
polygon,
context.directory / "image.0001.png",
parameters.get("invert", False),
)
else:
copy_color_images(images, context.directory)
color_path = min(context.directory.glob("image.0001.*"))
result = {}
normal_analysis = bool(parameters.get("normal_analysis"))
with analysis_frames(context, parameters, images) as model_images:
if len(model_images) != 1:
raise ValueError("Empty Image to Mesh currently supports one image")
analysis_path = model_images[0]
if shape in {"CUTOUT", "BALLOON"} and normal_analysis:
context.progress(0.4, "Estimating surface normal")
result["normal"] = _write_camera_normal(
context, parameters, analysis_path
).name
elif shape == "DEPTH_BALLOON":
from PIL import Image
with Image.open(images[0]) as selection_source:
selection_size = selection_source.size
context.progress(0.4, "Estimating Balloon depth")
depth_path, normal_path = _write_balloon_depth(
context,
parameters,
analysis_path,
polygon,
selection_size,
include_normal=normal_analysis,
)
result["depth"] = depth_path.name
if normal_path is not None:
result["normal"] = normal_path.name
elif shape in {"DEPTH_PLANE", "DEPTH_SURFACE"}:
context.progress(0.4, "Estimating image depth")
field_path, normal_path = _write_surface_depth(
context,
parameters,
analysis_path,
include_normal=normal_analysis,
)
result["field"] = field_path.name
if (
normal_analysis
and normal_path is None
and shape == "DEPTH_SURFACE"
):
normal_path = _write_camera_normal(
context, parameters, analysis_path
)
if normal_path is not None:
result["normal"] = normal_path.name
context.check_cancelled()
context.progress(1.0, "Mesh data is ready")
return result

View File

@@ -2,7 +2,7 @@ from ..geometry import build_surface, foreground_mesh_mask
from ..media import copy_color_images
from ..models import geometry_adapter, moge2
from ..outputs.surface import write_normal_texture, write_surface_archive
from .common import frame_progress_message, input_frames
from .common import analysis_frames, frame_progress_message, input_frames
from .foreground import write_optional
def run(context, parameters):
@@ -14,29 +14,32 @@ def run(context, parameters):
if len(images) != 1:
raise ValueError("Convert to Surface currently supports one image")
context.progress(0.12, "Estimating surface geometry")
if adapter is moge2:
prediction = adapter.infer(
model_manager,
parameters,
images,
include_points=True,
progress_callback=lambda index, total: context.progress(
0.12 + 0.58 * index / total,
frame_progress_message("Estimating MoGe-2 geometry", index, total),
),
cancel_check=context.check_cancelled,
)
else:
prediction = adapter.infer(
model_manager,
parameters,
images,
progress_callback=lambda index, total: context.progress(
0.12 + 0.58 * index / total,
frame_progress_message("Estimating DA3 geometry", index, total),
),
cancel_check=context.check_cancelled,
)
with analysis_frames(context, parameters, images) as model_images:
if len(model_images) != 1:
raise ValueError("Convert to Surface currently supports one image")
if adapter is moge2:
prediction = adapter.infer(
model_manager,
parameters,
model_images,
include_points=True,
progress_callback=lambda index, total: context.progress(
0.12 + 0.58 * index / total,
frame_progress_message("Estimating MoGe-2 geometry", index, total),
),
cancel_check=context.check_cancelled,
)
else:
prediction = adapter.infer(
model_manager,
parameters,
model_images,
progress_callback=lambda index, total: context.progress(
0.12 + 0.58 * index / total,
frame_progress_message("Estimating DA3 geometry", index, total),
),
cancel_check=context.check_cancelled,
)
context.check_cancelled()
frame = adapter.surface_frame(prediction)
color_paths = write_optional(

View File

@@ -25,7 +25,7 @@ class ModelSessionCache:
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.ben2_model is not None and self.ben2_key == key:
return self.ben2_model, 0.0
self.close()
self.release_ben2()
return self._load_ben2(model_dir, requested_device, key)
def _load_ben2(self, model_dir, requested_device, key):
@@ -43,7 +43,7 @@ class ModelSessionCache:
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.da3_model is not None and self.da3_key == key:
return self.da3_model
self.close()
self.release_geometry()
return self._load_da3(model_dir, requested_device, key)
def _load_da3(self, model_dir, requested_device, key):
@@ -60,7 +60,7 @@ class ModelSessionCache:
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.moge_model is not None and self.moge_key == key:
return self.moge_model
self.close()
self.release_geometry()
model = create_session(model_dir, requested_device)
self.moge_model = model
self.moge_key = key
@@ -72,7 +72,7 @@ class ModelSessionCache:
key = (str(Path(model_dir).resolve()), requested_device, "onnx")
if self.upscale_model is not None and self.upscale_key == key:
return self.upscale_model
self.close()
self.release_upscale()
model = create_session(model_dir, requested_device)
self.upscale_model = model
self.upscale_key = key
@@ -83,12 +83,9 @@ class ModelSessionCache:
self.ben2_key = None
self._release_runtime_cache()
def release_da3(self):
def release_geometry(self):
self.da3_model = None
self.da3_key = None
self._release_runtime_cache()
def release_moge(self):
self.moge_model = None
self.moge_key = None
self._release_runtime_cache()
@@ -110,15 +107,11 @@ class ModelSessionCache:
self._release_runtime_cache()
def snapshot(self):
geometry_key = self.da3_key or self.moge_key
return {
"ben2": Path(self.ben2_key[0]).name if self.ben2_key else "",
"da3": Path(self.da3_key[0]).name if self.da3_key else "",
"moge2": Path(self.moge_key[0]).name if self.moge_key else "",
"upscale": (
Path(self.upscale_key[0]).name
if self.upscale_key
else ""
),
"geometry": Path(geometry_key[0]).name if geometry_key else "",
"upscale": (Path(self.upscale_key[0]).name if self.upscale_key else ""),
}
def _release_runtime_cache(self):
@@ -176,6 +169,7 @@ class ModelManager:
total = len(missing)
for index, model_key in enumerate(missing):
def report(value, message, *, offset=index):
context.progress((offset + value) / total, message)