Refactor image processing and cutout functionalities

- Updated the naming convention for upscaled images to include underscores and file extensions.
- Renamed properties related to cutout pixel size to point spacing for clarity.
- Adjusted alpha region calculations to use a half-alpha threshold for better accuracy.
- Enhanced the handling of foreground mesh masks to consider half-alpha values.
- Introduced new functions for unbounded selections and cutout images, ensuring proper handling of source and selection images.
- Improved test coverage for cutout functionalities, including bounds mapping and alpha preservation.
- Updated tests to reflect changes in naming conventions and functionality adjustments.
This commit is contained in:
2026-08-27 12:01:47 +08:00
parent 2b79a342b0
commit 6c8358f92f
27 changed files with 1058 additions and 223 deletions

View File

@@ -186,7 +186,7 @@ def alpha_regions(alpha, max_resolution=512, threshold=0.5):
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)
mask = values >= 255.0 * threshold
regions = mask_regions(mask)
scale_x = source_size[0] / sampled_size[0]
scale_y = source_size[1] / sampled_size[1]

View File

@@ -116,7 +116,7 @@ def foreground_mesh_mask(foreground_path, depth_shape):
resample=Image.Resampling.BILINEAR,
)
alpha = alpha.filter(ImageFilter.GaussianBlur(radius=0.75))
return np.asarray(alpha) > 0
return np.asarray(alpha) >= round(255.0 * 0.5)
def confidence_percentile_threshold(depth, confidence, percentile):

View File

@@ -1,3 +1,5 @@
import json
from ..media import copy_color_images
from ..models import geometry_adapter, moge2
from .background import remove_image_background
@@ -45,7 +47,7 @@ def _write_field(frame, output_path):
def _write_masked_color(input_path, polygon, output_path, invert=False):
import numpy as np
from PIL import Image, ImageChops, ImageFilter, ImageOps
from PIL import Image, ImageFilter, ImageOps
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
@@ -55,9 +57,8 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
).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[:, :, 3] = np.asarray(selection, dtype=np.uint8)
pixels[pixels[:, :, 3] == 0, :3] = 0
Image.fromarray(pixels, mode="RGBA").save(
output_path,
@@ -67,6 +68,37 @@ def _write_masked_color(input_path, polygon, output_path, invert=False):
return output_path
def _write_unbounded_selection(
input_path,
polygon,
output_path,
invert=False,
):
import numpy as np
from PIL import Image, ImageFilter
if invert:
_write_masked_color(input_path, polygon, output_path, invert=True)
with Image.open(input_path) as source:
return output_path, (0, 0, source.width, source.height)
canvas, selection_mask, bounds = create_selection_canvas(
input_path, polygon, 'NONZERO'
)
selection = Image.fromarray(
selection_mask.astype(np.uint8) * 255,
mode='L',
).filter(ImageFilter.GaussianBlur(radius=0.75))
pixels = np.asarray(canvas, dtype=np.uint8).copy()
pixels[:, :, 3] = np.asarray(selection, 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, bounds
def _write_refined_color(context, parameters, input_path, polygon, output_path):
from PIL import Image, ImageChops
@@ -100,6 +132,77 @@ def _write_refined_color(context, parameters, input_path, polygon, output_path):
return output_path
def _write_cutout_images(
input_path,
selection_path,
output_directory,
selection_bounds=None,
):
import numpy as np
from PIL import Image
with Image.open(input_path) as opened:
source = opened.convert("RGBA")
with Image.open(selection_path) as opened:
selection = opened.convert("RGBA")
if selection_bounds is None and selection.size != source.size:
raise ValueError("The Cutout selection does not match the source image")
if selection_bounds is None:
selection_bounds = (0, 0, source.width, source.height)
selection_pixels = np.asarray(selection, dtype=np.uint8).copy()
alpha = selection_pixels[:, :, 3]
alpha[alpha <= 1] = 0
selection_pixels[alpha == 0, :3] = 0
selection = Image.fromarray(selection_pixels, mode="RGBA")
visible_y, visible_x = np.nonzero(alpha >= 128)
if not len(visible_x):
raise ValueError("The Cutout selection contains no visible pixels")
bounds = (
int(visible_x.min()),
int(visible_y.min()),
int(visible_x.max()) + 1,
int(visible_y.max()) + 1,
)
content_bounds = bounds
selection_left, selection_top, _selection_right, _selection_bottom = (
selection_bounds
)
bounds = (
selection_left + content_bounds[0],
selection_top + content_bounds[1],
selection_left + content_bounds[2],
selection_top + content_bounds[3],
)
color_canvas = Image.new('RGBA', selection.size, (0, 0, 0, 0))
color_canvas.paste(source, (-selection_left, -selection_top))
color = color_canvas.crop(content_bounds)
color_path = output_directory / "image.0001.png"
color.save(color_path, format="PNG", compress_level=1)
selection_crop = selection.crop(content_bounds)
selection_path = output_directory / "selection.png"
selection_crop.save(selection_path, format="PNG", compress_level=1)
analysis_pixels = np.asarray(color, dtype=np.uint8).copy()
mask_alpha = np.asarray(selection_crop, dtype=np.uint8)[:, :, 3]
analysis_pixels[:, :, 3] = np.minimum(
analysis_pixels[:, :, 3],
mask_alpha,
)
analysis_pixels[mask_alpha < 128] = 0
analysis_path = output_directory / "analysis.png"
Image.fromarray(analysis_pixels, mode="RGBA").save(
analysis_path,
format="PNG",
compress_level=1,
)
bounds_path = output_directory / "bounds.json"
bounds_path.write_text(
json.dumps(bounds, separators=(",", ":")),
encoding="utf-8",
)
return color_path, selection_path, analysis_path, bounds_path
def _write_camera_normal_from_prediction(prediction, color_path, output_path):
import numpy as np
from PIL import Image
@@ -148,25 +251,15 @@ def _write_balloon_depth(
context,
parameters,
input_path,
polygon,
selection_size=None,
include_normal=False,
):
import numpy as np
from PIL import Image
with Image.open(input_path) as source:
rgba = np.asarray(source.convert("RGBA"), dtype=np.uint8)
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",
)
selection_mask = rgba[:, :, 3] >= 128
prediction = moge2.infer(
context.resource("model_manager"),
parameters,
@@ -190,7 +283,6 @@ def _write_balloon_depth(
)
return depth_path, normal_path
def _write_surface_depth(
context, parameters, color_path, include_normal=False
):
@@ -230,49 +322,59 @@ def run(context, parameters):
raise ValueError("Empty Image to Mesh currently supports one image")
shape = parameters["shape"]
polygon = parameters.get("polygon")
analysis_path = None
result = {}
if polygon:
selection_bounds = None
full_selection_path = context.directory / "selection-full.png"
if parameters.get("refine_selection"):
color_path = _write_refined_color(
_write_refined_color(
context,
parameters,
images[0],
polygon,
context.directory / "image.0001.png",
full_selection_path,
)
else:
color_path = _write_masked_color(
_selection_path, selection_bounds = _write_unbounded_selection(
images[0],
polygon,
context.directory / "image.0001.png",
full_selection_path,
parameters.get("invert", False),
)
color_path, selection_path, analysis_path, bounds_path = (
_write_cutout_images(
images[0],
full_selection_path,
context.directory,
selection_bounds,
)
)
result.update(
selection=selection_path.name,
bounds=bounds_path.name,
)
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]
model_path = analysis_path or 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
context, parameters, model_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,
model_path,
include_normal=normal_analysis,
)
result["depth"] = depth_path.name
@@ -283,7 +385,7 @@ def run(context, parameters):
field_path, normal_path = _write_surface_depth(
context,
parameters,
analysis_path,
model_path,
include_normal=normal_analysis,
)
result["field"] = field_path.name
@@ -293,7 +395,7 @@ def run(context, parameters):
and shape == "DEPTH_SURFACE"
):
normal_path = _write_camera_normal(
context, parameters, analysis_path
context, parameters, model_path
)
if normal_path is not None:
result["normal"] = normal_path.name