angle node kind of working
This commit is contained in:
@@ -860,6 +860,216 @@ def _apply_markup_overlay(image: np.ndarray, field: DataField | None, spec: dict
|
||||
return np.asarray(pil_image, dtype=np.uint8)
|
||||
|
||||
|
||||
def _angle_label_base_position(spec: dict[str, Any]) -> tuple[float, float]:
|
||||
x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0))
|
||||
y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0))
|
||||
xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0))
|
||||
ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0))
|
||||
x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0))
|
||||
y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0))
|
||||
|
||||
va = np.array([x1 - xm, y1 - ym], dtype=np.float64)
|
||||
vb = np.array([x2 - xm, y2 - ym], dtype=np.float64)
|
||||
len_a = float(np.hypot(va[0], va[1]))
|
||||
len_b = float(np.hypot(vb[0], vb[1]))
|
||||
if len_a <= 1e-6 or len_b <= 1e-6:
|
||||
return (float(np.clip(xm, 0.0, 1.0)), float(np.clip(ym - 0.14, 0.0, 1.0)))
|
||||
|
||||
unit = np.array([
|
||||
va[0] / len_a + vb[0] / len_b,
|
||||
va[1] / len_a + vb[1] / len_b,
|
||||
], dtype=np.float64)
|
||||
unit_len = float(np.hypot(unit[0], unit[1]))
|
||||
if unit_len <= 1e-6:
|
||||
bisector = np.array([0.0, -1.0], dtype=np.float64)
|
||||
else:
|
||||
bisector = unit / unit_len
|
||||
|
||||
return (
|
||||
float(np.clip(xm + bisector[0] * 0.14, 0.0, 1.0)),
|
||||
float(np.clip(ym + bisector[1] * 0.14, 0.0, 1.0)),
|
||||
)
|
||||
|
||||
|
||||
def _sanitize_angle_overlay_thickness(value: Any, default: float = 1.35) -> float:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
numeric = default
|
||||
if not np.isfinite(numeric):
|
||||
numeric = default
|
||||
return float(np.clip(numeric, 0.35, 6.0))
|
||||
|
||||
|
||||
def _sanitize_angle_overlay_color(value: Any, default: str = "#ff0000") -> str:
|
||||
if isinstance(value, str):
|
||||
text = value.strip()
|
||||
if len(text) == 4 and text.startswith("#"):
|
||||
text = "#" + "".join(ch * 2 for ch in text[1:])
|
||||
if len(text) == 7 and text.startswith("#"):
|
||||
try:
|
||||
int(text[1:], 16)
|
||||
return text.lower()
|
||||
except ValueError:
|
||||
pass
|
||||
return default
|
||||
|
||||
|
||||
def _hex_to_rgb(color: str) -> tuple[int, int, int]:
|
||||
return tuple(int(color[index:index + 2], 16) for index in (1, 3, 5))
|
||||
|
||||
|
||||
def _mix_rgb(color_a: tuple[int, int, int], color_b: tuple[int, int, int], weight: float) -> tuple[int, int, int]:
|
||||
alpha = float(np.clip(weight, 0.0, 1.0))
|
||||
return tuple(
|
||||
int(round(a * (1.0 - alpha) + b * alpha))
|
||||
for a, b in zip(color_a, color_b)
|
||||
)
|
||||
|
||||
|
||||
def _draw_round_line(draw, start: tuple[float, float], end: tuple[float, float], fill, width: int) -> None:
|
||||
width = max(1, int(round(width)))
|
||||
draw.line((start, end), fill=fill, width=width)
|
||||
radius = max(1, int(np.ceil(width / 2.0)))
|
||||
for px, py in (start, end):
|
||||
draw.ellipse((px - radius, py - radius, px + radius, py + radius), fill=fill)
|
||||
|
||||
|
||||
def _draw_dashed_polyline(
|
||||
draw,
|
||||
points: list[tuple[float, float]],
|
||||
fill,
|
||||
width: int,
|
||||
dash_length: float,
|
||||
gap_length: float,
|
||||
) -> None:
|
||||
if len(points) < 2:
|
||||
return
|
||||
|
||||
dash_length = max(1.0, float(dash_length))
|
||||
gap_length = max(1.0, float(gap_length))
|
||||
cycle_length = dash_length + gap_length
|
||||
traversed = 0.0
|
||||
|
||||
for start, end in zip(points[:-1], points[1:]):
|
||||
seg_len = float(np.hypot(end[0] - start[0], end[1] - start[1]))
|
||||
if seg_len <= 1e-6:
|
||||
continue
|
||||
if ((traversed + 0.5 * seg_len) % cycle_length) < dash_length:
|
||||
_draw_round_line(draw, start, end, fill=fill, width=width)
|
||||
traversed += seg_len
|
||||
|
||||
|
||||
def _apply_angle_measure_overlay(image: np.ndarray, field: DataField | None, spec: dict[str, Any]) -> np.ndarray:
|
||||
from PIL import Image, ImageDraw
|
||||
|
||||
current = np.asarray(image, dtype=np.uint8)
|
||||
if current.ndim == 2:
|
||||
current = np.repeat(current[:, :, np.newaxis], 3, axis=2)
|
||||
|
||||
pil_image = Image.fromarray(current.copy()).convert("RGBA")
|
||||
draw = ImageDraw.Draw(pil_image, "RGBA")
|
||||
field_width = max(1, int(field.xres)) if isinstance(field, DataField) else max(1, current.shape[1])
|
||||
field_height = max(1, int(field.yres)) if isinstance(field, DataField) else max(1, current.shape[0])
|
||||
longest_dim = max(field_width, field_height)
|
||||
|
||||
x1 = float(np.clip(spec.get("x1", 0.0), 0.0, 1.0))
|
||||
y1 = float(np.clip(spec.get("y1", 0.0), 0.0, 1.0))
|
||||
xm = float(np.clip(spec.get("xm", 0.5), 0.0, 1.0))
|
||||
ym = float(np.clip(spec.get("ym", 0.5), 0.0, 1.0))
|
||||
x2 = float(np.clip(spec.get("x2", 1.0), 0.0, 1.0))
|
||||
y2 = float(np.clip(spec.get("y2", 0.0), 0.0, 1.0))
|
||||
label_dx = float(spec.get("label_dx", 0.0) or 0.0)
|
||||
label_dy = float(spec.get("label_dy", 0.0) or 0.0)
|
||||
angle_deg = float(spec.get("angle_deg", 0.0) or 0.0)
|
||||
color_hex = _sanitize_angle_overlay_color(spec.get("color", "#ff0000"))
|
||||
line_thickness = _sanitize_angle_overlay_thickness(spec.get("line_thickness", 1.35))
|
||||
base_rgb = _hex_to_rgb(color_hex)
|
||||
arc_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.42)
|
||||
badge_text_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.72)
|
||||
badge_border_rgb = _mix_rgb(base_rgb, (255, 255, 255), 0.32)
|
||||
|
||||
line_width = max(1, int(round(longest_dim * line_thickness / 100.0)))
|
||||
arc_width = max(1, int(round(longest_dim * max(0.85, line_thickness * 0.78) / 100.0)))
|
||||
line_color = (*base_rgb, 255)
|
||||
arc_color = (*arc_rgb, 242)
|
||||
|
||||
points = [
|
||||
(x1 * field_width, y1 * field_height),
|
||||
(xm * field_width, ym * field_height),
|
||||
(x2 * field_width, y2 * field_height),
|
||||
]
|
||||
_draw_round_line(draw, points[0], points[1], fill=line_color, width=line_width)
|
||||
_draw_round_line(draw, points[1], points[2], fill=line_color, width=line_width)
|
||||
|
||||
va = np.array([x1 - xm, y1 - ym], dtype=np.float64)
|
||||
vb = np.array([x2 - xm, y2 - ym], dtype=np.float64)
|
||||
len_a = float(np.hypot(va[0], va[1]))
|
||||
len_b = float(np.hypot(vb[0], vb[1]))
|
||||
if len_a > 1e-6 and len_b > 1e-6:
|
||||
radius = min(0.12, 0.38 * min(len_a, len_b))
|
||||
start_angle = float(np.arctan2(va[1], va[0]))
|
||||
delta = float(np.arctan2(va[0] * vb[1] - va[1] * vb[0], np.dot(va, vb)))
|
||||
arc_points = []
|
||||
for theta in np.linspace(start_angle, start_angle + delta, 48):
|
||||
arc_points.append((
|
||||
(xm + radius * np.cos(theta)) * field_width,
|
||||
(ym + radius * np.sin(theta)) * field_height,
|
||||
))
|
||||
if len(arc_points) >= 2:
|
||||
_draw_dashed_polyline(
|
||||
draw,
|
||||
arc_points,
|
||||
fill=arc_color,
|
||||
width=arc_width,
|
||||
dash_length=max(4.0, longest_dim * 0.05),
|
||||
gap_length=max(3.0, longest_dim * 0.03),
|
||||
)
|
||||
|
||||
base_label_x, base_label_y = _angle_label_base_position(spec)
|
||||
label_x = float(np.clip(base_label_x + label_dx, 0.0, 1.0))
|
||||
label_y = float(np.clip(base_label_y + label_dy, 0.0, 1.0))
|
||||
label_text = f"{angle_deg:.1f} deg"
|
||||
text_image = _render_overlay_text(
|
||||
label_text,
|
||||
max(10, int(round(longest_dim / 26.0))),
|
||||
badge_text_rgb,
|
||||
)
|
||||
|
||||
bg_pad_x = max(5, int(round(text_image.size[0] * 0.16)))
|
||||
bg_pad_y = max(3, int(round(text_image.size[1] * 0.18)))
|
||||
bg_width = text_image.size[0] + 2 * bg_pad_x
|
||||
bg_height = text_image.size[1] + 2 * bg_pad_y
|
||||
center_x = int(round(label_x * field_width))
|
||||
center_y = int(round(label_y * field_height))
|
||||
bg_left = max(0, min(field_width - bg_width, center_x - bg_width // 2))
|
||||
bg_top = max(0, min(field_height - bg_height, center_y - bg_height // 2))
|
||||
bg_right = bg_left + bg_width
|
||||
bg_bottom = bg_top + bg_height
|
||||
|
||||
shadow_offset = max(1, int(round(bg_height * 0.08)))
|
||||
draw.rounded_rectangle(
|
||||
(
|
||||
bg_left,
|
||||
min(field_height, bg_top + shadow_offset),
|
||||
bg_right,
|
||||
min(field_height, bg_bottom + shadow_offset),
|
||||
),
|
||||
radius=max(6, bg_height // 2),
|
||||
fill=(15, 23, 42, 86),
|
||||
)
|
||||
draw.rounded_rectangle(
|
||||
(bg_left, bg_top, bg_right, bg_bottom),
|
||||
radius=max(6, bg_height // 2),
|
||||
fill=(15, 23, 42, 230),
|
||||
outline=(*badge_border_rgb, 115),
|
||||
width=1,
|
||||
)
|
||||
pil_image.paste(text_image, (bg_left + bg_pad_x, bg_top + bg_pad_y), text_image)
|
||||
|
||||
return np.asarray(pil_image.convert("RGB"), dtype=np.uint8)
|
||||
|
||||
|
||||
def render_datafield_preview(df: DataField, colormap: Any = "gray") -> np.ndarray:
|
||||
current = datafield_to_uint8(df, colormap)
|
||||
for overlay in df.overlays:
|
||||
@@ -870,6 +1080,8 @@ def render_datafield_preview(df: DataField, colormap: Any = "gray") -> np.ndarra
|
||||
current = _apply_annotation_overlay(current, df, colormap, overlay)
|
||||
elif kind == "markup":
|
||||
current = _apply_markup_overlay(current, df, overlay)
|
||||
elif kind == "angle_measure":
|
||||
current = _apply_angle_measure_overlay(current, df, overlay)
|
||||
return current
|
||||
|
||||
|
||||
|
||||
@@ -34,6 +34,7 @@ MENU_LAYOUT: dict[str, list[str]] = {
|
||||
"Overlay": [
|
||||
"Markup",
|
||||
"Annotations",
|
||||
"AngleMeasure",
|
||||
],
|
||||
"Modify": [
|
||||
"ColormapAdjust",
|
||||
|
||||
@@ -38,6 +38,7 @@ from backend.nodes import (
|
||||
color_map,
|
||||
font_node,
|
||||
annotations,
|
||||
angle_measure,
|
||||
markup,
|
||||
preview_image,
|
||||
view_3d,
|
||||
|
||||
215
backend/nodes/angle_measure.py
Normal file
215
backend/nodes/angle_measure.py
Normal file
@@ -0,0 +1,215 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import numpy as np
|
||||
|
||||
from backend.data_types import (
|
||||
DataField,
|
||||
ImageData,
|
||||
MeasureTable,
|
||||
_apply_angle_measure_overlay,
|
||||
encode_preview,
|
||||
image_metadata,
|
||||
image_to_uint8,
|
||||
render_datafield_preview,
|
||||
)
|
||||
from backend.execution_context import emit_overlay, emit_table
|
||||
from backend.node_registry import register_node
|
||||
from backend.nodes.helpers import _normalize_markup_color
|
||||
|
||||
|
||||
def _clamp01(value: float) -> float:
|
||||
return float(np.clip(value, 0.0, 1.0))
|
||||
|
||||
|
||||
def _sanitize_line_thickness(value: float | None, default: float = 1.35) -> float:
|
||||
try:
|
||||
numeric = float(value)
|
||||
except (TypeError, ValueError):
|
||||
numeric = default
|
||||
if not np.isfinite(numeric):
|
||||
numeric = default
|
||||
return float(np.clip(numeric, 0.35, 6.0))
|
||||
|
||||
|
||||
def _angle_metrics(ax: float, ay: float, vx: float, vy: float, bx: float, by: float) -> tuple[float, float, float]:
|
||||
va = np.array([ax - vx, ay - vy], dtype=np.float64)
|
||||
vb = np.array([bx - vx, by - vy], dtype=np.float64)
|
||||
len_a = float(np.hypot(va[0], va[1]))
|
||||
len_b = float(np.hypot(vb[0], vb[1]))
|
||||
|
||||
if len_a <= 1e-12 or len_b <= 1e-12:
|
||||
return (0.0, len_a, len_b)
|
||||
|
||||
cos_theta = float(np.dot(va, vb) / (len_a * len_b))
|
||||
cos_theta = float(np.clip(cos_theta, -1.0, 1.0))
|
||||
angle_deg = float(np.degrees(np.arccos(cos_theta)))
|
||||
return (angle_deg, len_a, len_b)
|
||||
|
||||
|
||||
def _angle_overlay_spec(
|
||||
x1: float,
|
||||
y1: float,
|
||||
xm: float,
|
||||
ym: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
angle_deg: float,
|
||||
label_dx: float,
|
||||
label_dy: float,
|
||||
line_thickness: float,
|
||||
color: str,
|
||||
) -> dict[str, float | str]:
|
||||
return {
|
||||
"kind": "angle_measure",
|
||||
"x1": x1,
|
||||
"y1": y1,
|
||||
"xm": xm,
|
||||
"ym": ym,
|
||||
"x2": x2,
|
||||
"y2": y2,
|
||||
"angle_deg": float(angle_deg),
|
||||
"label_dx": float(label_dx),
|
||||
"label_dy": float(label_dy),
|
||||
"line_thickness": float(line_thickness),
|
||||
"color": str(color),
|
||||
}
|
||||
|
||||
|
||||
@register_node(display_name="Angle Measure")
|
||||
class AngleMeasure:
|
||||
_CUSTOM_PREVIEW = True
|
||||
|
||||
@classmethod
|
||||
def INPUT_TYPES(cls):
|
||||
return {
|
||||
"required": {
|
||||
"input": ("ANNOTATION_SOURCE", {"label": "Input"}),
|
||||
"color": ("STRING", {"default": "#ff0000", "color_picker": True}),
|
||||
"line_thickness": ("FLOAT", {
|
||||
"default": 1.35,
|
||||
"min": 0.35,
|
||||
"max": 6.0,
|
||||
"step": 0.05,
|
||||
"label": "line thickness",
|
||||
"hide_when_input_connected": "line_thickness_input",
|
||||
}),
|
||||
"x1": ("FLOAT", {"default": 0.22, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"y1": ("FLOAT", {"default": 0.72, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"xm": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"ym": ("FLOAT", {"default": 0.50, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"x2": ("FLOAT", {"default": 0.78, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"y2": ("FLOAT", {"default": 0.28, "min": 0.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"label_dx": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
"label_dy": ("FLOAT", {"default": 0.0, "min": -1.0, "max": 1.0, "step": 0.01, "hidden": True}),
|
||||
},
|
||||
"optional": {
|
||||
"line_thickness_input": ("FLOAT", {"label": "line thickness"}),
|
||||
},
|
||||
}
|
||||
|
||||
RETURN_TYPES = ("ANNOTATION_SOURCE", "MEASURE_TABLE")
|
||||
RETURN_NAMES = ("output", "measurements")
|
||||
FUNCTION = "process"
|
||||
|
||||
DESCRIPTION = (
|
||||
"Measure the included angle between two draggable line segments over a DATA_FIELD or IMAGE. "
|
||||
"Drag either endpoint to change that arm, drag the middle joint to move the whole widget, "
|
||||
"drag the angle label to reposition it, choose the overlay color, and adjust line thickness "
|
||||
"with the widget or a FLOAT input."
|
||||
)
|
||||
|
||||
def process(
|
||||
self,
|
||||
input,
|
||||
color: str,
|
||||
line_thickness: float,
|
||||
x1: float,
|
||||
y1: float,
|
||||
xm: float,
|
||||
ym: float,
|
||||
x2: float,
|
||||
y2: float,
|
||||
label_dx: float,
|
||||
label_dy: float,
|
||||
line_thickness_input: float | None = None,
|
||||
) -> tuple:
|
||||
x1 = _clamp01(x1)
|
||||
y1 = _clamp01(y1)
|
||||
xm = _clamp01(xm)
|
||||
ym = _clamp01(ym)
|
||||
x2 = _clamp01(x2)
|
||||
y2 = _clamp01(y2)
|
||||
label_dx = float(np.clip(label_dx, -1.0, 1.0))
|
||||
label_dy = float(np.clip(label_dy, -1.0, 1.0))
|
||||
resolved_color = _normalize_markup_color(color, default="#ff0000")
|
||||
resolved_line_thickness = _sanitize_line_thickness(
|
||||
line_thickness_input if line_thickness_input is not None else line_thickness,
|
||||
)
|
||||
|
||||
if isinstance(input, DataField):
|
||||
preview_base = render_datafield_preview(input, input.colormap)
|
||||
ax = float(input.xoff + x1 * input.xreal)
|
||||
ay = float(input.yoff + y1 * input.yreal)
|
||||
vx = float(input.xoff + xm * input.xreal)
|
||||
vy = float(input.yoff + ym * input.yreal)
|
||||
bx = float(input.xoff + x2 * input.xreal)
|
||||
by = float(input.yoff + y2 * input.yreal)
|
||||
length_unit = input.si_unit_xy
|
||||
else:
|
||||
preview_base = image_to_uint8(input)
|
||||
height, width = preview_base.shape[:2]
|
||||
ax = float(x1 * max(width - 1, 0))
|
||||
ay = float(y1 * max(height - 1, 0))
|
||||
vx = float(xm * max(width - 1, 0))
|
||||
vy = float(ym * max(height - 1, 0))
|
||||
bx = float(x2 * max(width - 1, 0))
|
||||
by = float(y2 * max(height - 1, 0))
|
||||
length_unit = "px"
|
||||
|
||||
angle_deg, length_a, length_b = _angle_metrics(ax, ay, vx, vy, bx, by)
|
||||
angle_overlay = _angle_overlay_spec(
|
||||
x1=x1,
|
||||
y1=y1,
|
||||
xm=xm,
|
||||
ym=ym,
|
||||
x2=x2,
|
||||
y2=y2,
|
||||
angle_deg=angle_deg,
|
||||
label_dx=label_dx,
|
||||
label_dy=label_dy,
|
||||
line_thickness=resolved_line_thickness,
|
||||
color=resolved_color,
|
||||
)
|
||||
table = MeasureTable([
|
||||
{"quantity": "Angle", "value": angle_deg, "unit": "deg"},
|
||||
{"quantity": "Arm A length", "value": length_a, "unit": length_unit},
|
||||
{"quantity": "Arm B length", "value": length_b, "unit": length_unit},
|
||||
{"quantity": "Arm A x", "value": ax, "unit": length_unit},
|
||||
{"quantity": "Arm A y", "value": ay, "unit": length_unit},
|
||||
{"quantity": "Vertex x", "value": vx, "unit": length_unit},
|
||||
{"quantity": "Vertex y", "value": vy, "unit": length_unit},
|
||||
{"quantity": "Arm B x", "value": bx, "unit": length_unit},
|
||||
{"quantity": "Arm B y", "value": by, "unit": length_unit},
|
||||
])
|
||||
|
||||
if isinstance(input, DataField):
|
||||
output = input.replace(
|
||||
overlays=[
|
||||
*input.overlays,
|
||||
angle_overlay,
|
||||
],
|
||||
)
|
||||
else:
|
||||
output = ImageData(
|
||||
_apply_angle_measure_overlay(preview_base, None, angle_overlay),
|
||||
metadata=image_metadata(input),
|
||||
)
|
||||
|
||||
emit_overlay({
|
||||
"section_title": "Angle",
|
||||
"image": encode_preview(preview_base),
|
||||
**angle_overlay,
|
||||
})
|
||||
emit_table(table)
|
||||
|
||||
return (output, table)
|
||||
@@ -102,12 +102,12 @@ class Cursors:
|
||||
})
|
||||
|
||||
table = MeasureTable([
|
||||
{"quantity": "dx", "value": xb - xa, "unit": x_unit},
|
||||
{"quantity": "dy", "value": yb - ya, "unit": y_unit},
|
||||
{"quantity": "A x", "value": xa, "unit": x_unit},
|
||||
{"quantity": "A y", "value": ya, "unit": y_unit},
|
||||
{"quantity": "B x", "value": xb, "unit": x_unit},
|
||||
{"quantity": "B y", "value": yb, "unit": y_unit},
|
||||
{"quantity": "dx", "value": xb - xa, "unit": x_unit},
|
||||
{"quantity": "dy", "value": yb - ya, "unit": y_unit},
|
||||
])
|
||||
return (table, ((x1, y1), (x2, y2)))
|
||||
|
||||
|
||||
@@ -87,11 +87,11 @@ class Histogram:
|
||||
})
|
||||
|
||||
table = MeasureTable([
|
||||
{"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z},
|
||||
{"quantity": "delta Y", "value": yb - ya, "unit": count_unit},
|
||||
{"quantity": "A position", "value": xa, "unit": field.si_unit_z},
|
||||
{"quantity": "A count", "value": ya, "unit": count_unit},
|
||||
{"quantity": "B position", "value": xb, "unit": field.si_unit_z},
|
||||
{"quantity": "B count", "value": yb, "unit": count_unit},
|
||||
{"quantity": "delta X", "value": xb - xa, "unit": field.si_unit_z},
|
||||
{"quantity": "delta Y", "value": yb - ya, "unit": count_unit},
|
||||
])
|
||||
return (table, ((x1, y1), (x2, y2)))
|
||||
|
||||
Reference in New Issue
Block a user