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)))
|
||||
|
||||
214
frontend/src/AngleMeasureOverlay.jsx
Normal file
214
frontend/src/AngleMeasureOverlay.jsx
Normal file
@@ -0,0 +1,214 @@
|
||||
import React, { useRef, useState, useCallback } from 'react';
|
||||
|
||||
import {
|
||||
getAngleLabelBasePosition,
|
||||
getAngleLabelPosition,
|
||||
measureAngleDegrees,
|
||||
moveAngleWidget,
|
||||
round3,
|
||||
} from './angleMeasureGeometry.js';
|
||||
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
function sanitizeHexColor(value, fallback = '#ff0000') {
|
||||
if (typeof value !== 'string') return fallback;
|
||||
const text = value.trim();
|
||||
return /^#[0-9a-fA-F]{6}$/.test(text) ? text.toLowerCase() : fallback;
|
||||
}
|
||||
|
||||
function hexToRgb(value) {
|
||||
const color = sanitizeHexColor(value);
|
||||
return {
|
||||
r: parseInt(color.slice(1, 3), 16),
|
||||
g: parseInt(color.slice(3, 5), 16),
|
||||
b: parseInt(color.slice(5, 7), 16),
|
||||
};
|
||||
}
|
||||
|
||||
function mixColor(baseColor, mixWith, weight) {
|
||||
const alpha = Math.max(0, Math.min(1, Number(weight) || 0));
|
||||
const base = hexToRgb(baseColor);
|
||||
const target = hexToRgb(mixWith);
|
||||
const r = Math.round((base.r * (1 - alpha)) + (target.r * alpha));
|
||||
const g = Math.round((base.g * (1 - alpha)) + (target.g * alpha));
|
||||
const b = Math.round((base.b * (1 - alpha)) + (target.b * alpha));
|
||||
return `rgb(${r}, ${g}, ${b})`;
|
||||
}
|
||||
|
||||
function formatAngle(value) {
|
||||
const numeric = Number(value);
|
||||
if (!Number.isFinite(numeric)) return '0.0 deg';
|
||||
return `${numeric.toFixed(1)} deg`;
|
||||
}
|
||||
|
||||
function buildAngleArcPath(x1, y1, xm, ym, x2, y2) {
|
||||
const va = { x: x1 - xm, y: y1 - ym };
|
||||
const vb = { x: x2 - xm, y: y2 - ym };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
const lenB = Math.hypot(vb.x, vb.y);
|
||||
if (lenA <= 1e-6 || lenB <= 1e-6) return '';
|
||||
|
||||
const radius = Math.min(0.12, 0.38 * Math.min(lenA, lenB));
|
||||
const start = { x: xm + (va.x / lenA) * radius, y: ym + (va.y / lenA) * radius };
|
||||
const end = { x: xm + (vb.x / lenB) * radius, y: ym + (vb.y / lenB) * radius };
|
||||
const cross = (va.x * vb.y) - (va.y * vb.x);
|
||||
|
||||
return [
|
||||
`M ${start.x * 100} ${start.y * 100}`,
|
||||
`A ${radius * 100} ${radius * 100} 0 0 ${cross >= 0 ? 1 : 0} ${end.x * 100} ${end.y * 100}`,
|
||||
].join(' ');
|
||||
}
|
||||
|
||||
export default function AngleMeasureOverlay({
|
||||
image,
|
||||
x1,
|
||||
y1,
|
||||
xm,
|
||||
ym,
|
||||
x2,
|
||||
y2,
|
||||
labelDx,
|
||||
labelDy,
|
||||
angleDeg,
|
||||
color,
|
||||
lineThickness,
|
||||
nodeId,
|
||||
onWidgetChange,
|
||||
}) {
|
||||
const containerRef = useRef(null);
|
||||
const [dragging, setDragging] = useState(null);
|
||||
const resolvedColor = sanitizeHexColor(color, '#ff0000');
|
||||
const resolvedLineThickness = Math.max(0.35, Math.min(6, Number(lineThickness) || 1.35));
|
||||
const resolvedArcThickness = Math.max(0.85, resolvedLineThickness * 0.78);
|
||||
const resolvedArcColor = mixColor(resolvedColor, '#ffffff', 0.42);
|
||||
const resolvedMidColor = mixColor(resolvedColor, '#ffffff', 0.72);
|
||||
const resolvedBadgeTextColor = mixColor(resolvedColor, '#ffffff', 0.72);
|
||||
const resolvedBadgeBorderColor = mixColor(resolvedColor, '#ffffff', 0.32);
|
||||
|
||||
const getCoords = useCallback((event) => {
|
||||
const rect = containerRef.current.getBoundingClientRect();
|
||||
return {
|
||||
fx: clamp01((event.clientX - rect.left) / rect.width),
|
||||
fy: clamp01((event.clientY - rect.top) / rect.height),
|
||||
};
|
||||
}, []);
|
||||
|
||||
const updateWidgets = useCallback((updates) => {
|
||||
Object.entries(updates).forEach(([name, value]) => {
|
||||
onWidgetChange(nodeId, name, value);
|
||||
});
|
||||
}, [nodeId, onWidgetChange]);
|
||||
|
||||
const onPointerDown = useCallback((handle) => (event) => {
|
||||
event.stopPropagation();
|
||||
event.preventDefault();
|
||||
event.currentTarget.setPointerCapture(event.pointerId);
|
||||
|
||||
if (handle === 'mid') {
|
||||
const start = getCoords(event);
|
||||
setDragging({
|
||||
handle,
|
||||
start,
|
||||
points: { x1, y1, xm, ym, x2, y2 },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
setDragging({ handle });
|
||||
}, [getCoords, x1, y1, xm, ym, x2, y2]);
|
||||
|
||||
const onPointerMove = useCallback((event) => {
|
||||
if (!dragging || !containerRef.current) return;
|
||||
const { fx, fy } = getCoords(event);
|
||||
|
||||
if (dragging.handle === 'mid') {
|
||||
updateWidgets(moveAngleWidget(
|
||||
dragging.points,
|
||||
fx - dragging.start.fx,
|
||||
fy - dragging.start.fy,
|
||||
));
|
||||
return;
|
||||
}
|
||||
|
||||
if (dragging.handle === 'p1') {
|
||||
updateWidgets({ x1: round3(fx), y1: round3(fy) });
|
||||
} else if (dragging.handle === 'p2') {
|
||||
updateWidgets({ x2: round3(fx), y2: round3(fy) });
|
||||
} else if (dragging.handle === 'label') {
|
||||
const base = getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2);
|
||||
updateWidgets({
|
||||
label_dx: round3(fx - base.x),
|
||||
label_dy: round3(fy - base.y),
|
||||
});
|
||||
}
|
||||
}, [dragging, getCoords, updateWidgets, x1, y1, xm, ym, x2, y2]);
|
||||
|
||||
const onPointerUp = useCallback(() => {
|
||||
setDragging(null);
|
||||
}, []);
|
||||
|
||||
const displayedAngle = Number.isFinite(Number(angleDeg))
|
||||
? Number(angleDeg)
|
||||
: measureAngleDegrees(x1, y1, xm, ym, x2, y2);
|
||||
const arcPath = buildAngleArcPath(x1, y1, xm, ym, x2, y2);
|
||||
const labelPosition = getAngleLabelPosition({ x1, y1, xm, ym, x2, y2 }, labelDx, labelDy);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="nodrag nowheel angle-overlay"
|
||||
style={{
|
||||
'--angle-line-color': resolvedColor,
|
||||
'--angle-arc-color': resolvedArcColor,
|
||||
'--angle-end-handle-color': resolvedColor,
|
||||
'--angle-mid-handle-color': resolvedMidColor,
|
||||
'--angle-badge-text-color': resolvedBadgeTextColor,
|
||||
'--angle-badge-border-color': resolvedBadgeBorderColor,
|
||||
'--angle-line-thickness': `${resolvedLineThickness}`,
|
||||
'--angle-arc-thickness': `${resolvedArcThickness}`,
|
||||
}}
|
||||
onPointerMove={onPointerMove}
|
||||
onPointerUp={onPointerUp}
|
||||
onLostPointerCapture={onPointerUp}
|
||||
>
|
||||
<img src={image} alt="angle source" draggable={false} className="angle-image" />
|
||||
<svg className="angle-svg" viewBox="0 0 100 100" preserveAspectRatio="none">
|
||||
<line className="angle-line" x1={x1 * 100} y1={y1 * 100} x2={xm * 100} y2={ym * 100} />
|
||||
<line className="angle-line" x1={xm * 100} y1={ym * 100} x2={x2 * 100} y2={y2 * 100} />
|
||||
{arcPath && <path className="angle-arc" d={arcPath} />}
|
||||
</svg>
|
||||
|
||||
<div
|
||||
className="angle-badge"
|
||||
style={{ left: `${labelPosition.x * 100}%`, top: `${labelPosition.y * 100}%` }}
|
||||
onPointerDown={onPointerDown('label')}
|
||||
>
|
||||
{formatAngle(displayedAngle)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="angle-handle angle-handle-end"
|
||||
style={{ left: `${x1 * 100}%`, top: `${y1 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p1')}
|
||||
>
|
||||
A
|
||||
</div>
|
||||
<div
|
||||
className="angle-handle angle-handle-mid"
|
||||
style={{ left: `${xm * 100}%`, top: `${ym * 100}%` }}
|
||||
onPointerDown={onPointerDown('mid')}
|
||||
>
|
||||
V
|
||||
</div>
|
||||
<div
|
||||
className="angle-handle angle-handle-end"
|
||||
style={{ left: `${x2 * 100}%`, top: `${y2 * 100}%` }}
|
||||
onPointerDown={onPointerDown('p2')}
|
||||
>
|
||||
B
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -7,6 +7,7 @@ const CrossSectionOverlay = lazy(() => import('./CrossSectionOverlay'));
|
||||
const CropBoxOverlay = lazy(() => import('./CropBoxOverlay'));
|
||||
const MaskPaintOverlay = lazy(() => import('./MaskPaintOverlay'));
|
||||
const MarkupOverlay = lazy(() => import('./MarkupOverlay'));
|
||||
const AngleMeasureOverlay = lazy(() => import('./AngleMeasureOverlay'));
|
||||
|
||||
import {
|
||||
DATA_TYPES, SOCKET_WIDGET_TYPES, TYPE_COLORS, CAT_COLORS,
|
||||
@@ -1018,6 +1019,8 @@ function CustomNode({ id, data }) {
|
||||
? 'Mask'
|
||||
: data.overlay?.kind === 'markup'
|
||||
? 'Markup'
|
||||
: data.overlay?.kind === 'angle_measure'
|
||||
? 'Angle'
|
||||
: data.overlay?.kind === 'crop_box'
|
||||
? 'Crop'
|
||||
: data.overlay?.kind === 'cursor_points'
|
||||
@@ -1301,6 +1304,25 @@ function CustomNode({ id, data }) {
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : data.overlay.kind === 'angle_measure' ? (
|
||||
<AngleMeasureOverlay
|
||||
image={data.overlay.image}
|
||||
x1={data.widgetValues.x1 ?? data.overlay.x1}
|
||||
y1={data.widgetValues.y1 ?? data.overlay.y1}
|
||||
xm={data.widgetValues.xm ?? data.overlay.xm}
|
||||
ym={data.widgetValues.ym ?? data.overlay.ym}
|
||||
x2={data.widgetValues.x2 ?? data.overlay.x2}
|
||||
y2={data.widgetValues.y2 ?? data.overlay.y2}
|
||||
labelDx={data.widgetValues.label_dx ?? data.overlay.label_dx ?? 0}
|
||||
labelDy={data.widgetValues.label_dy ?? data.overlay.label_dy ?? 0}
|
||||
angleDeg={data.overlay.angle_deg}
|
||||
color={data.widgetValues.color ?? data.overlay.color ?? '#ff0000'}
|
||||
lineThickness={connectedInputs?.has('line_thickness_input')
|
||||
? (data.overlay.line_thickness ?? data.widgetValues.line_thickness ?? 1.35)
|
||||
: (data.widgetValues.line_thickness ?? data.overlay.line_thickness ?? 1.35)}
|
||||
nodeId={id}
|
||||
onWidgetChange={ctx.onWidgetChange}
|
||||
/>
|
||||
) : (
|
||||
<CrossSectionOverlay
|
||||
image={data.overlay.image}
|
||||
|
||||
77
frontend/src/angleMeasureGeometry.js
Normal file
77
frontend/src/angleMeasureGeometry.js
Normal file
@@ -0,0 +1,77 @@
|
||||
function clamp01(value) {
|
||||
return Math.max(0, Math.min(1, Number(value) || 0));
|
||||
}
|
||||
|
||||
export function round3(value) {
|
||||
return Number.parseFloat(Number(value).toFixed(3));
|
||||
}
|
||||
|
||||
export function getAngleLabelBasePosition(x1, y1, xm, ym, x2, y2) {
|
||||
const va = { x: Number(x1) - Number(xm), y: Number(y1) - Number(ym) };
|
||||
const vb = { x: Number(x2) - Number(xm), y: Number(y2) - Number(ym) };
|
||||
const lenA = Math.hypot(va.x, va.y);
|
||||
const lenB = Math.hypot(vb.x, vb.y);
|
||||
|
||||
if (lenA <= 1e-6 || lenB <= 1e-6) {
|
||||
return { x: clamp01(xm), y: clamp01(Number(ym) - 0.14) };
|
||||
}
|
||||
|
||||
const unit = {
|
||||
x: (va.x / lenA) + (vb.x / lenB),
|
||||
y: (va.y / lenA) + (vb.y / lenB),
|
||||
};
|
||||
const unitLength = Math.hypot(unit.x, unit.y);
|
||||
const bisector = unitLength <= 1e-6
|
||||
? { x: 0, y: -1 }
|
||||
: { x: unit.x / unitLength, y: unit.y / unitLength };
|
||||
|
||||
return {
|
||||
x: clamp01(Number(xm) + bisector.x * 0.14),
|
||||
y: clamp01(Number(ym) + bisector.y * 0.14),
|
||||
};
|
||||
}
|
||||
|
||||
export function getAngleLabelPosition(points, labelDx = 0, labelDy = 0) {
|
||||
const base = getAngleLabelBasePosition(points.x1, points.y1, points.xm, points.ym, points.x2, points.y2);
|
||||
return {
|
||||
x: clamp01(base.x + (Number(labelDx) || 0)),
|
||||
y: clamp01(base.y + (Number(labelDy) || 0)),
|
||||
};
|
||||
}
|
||||
|
||||
export function moveAngleWidget(points, dx, dy) {
|
||||
const nextDx = Number(dx) || 0;
|
||||
const nextDy = Number(dy) || 0;
|
||||
const xs = [points.x1, points.xm, points.x2];
|
||||
const ys = [points.y1, points.ym, points.y2];
|
||||
const minX = Math.min(...xs);
|
||||
const maxX = Math.max(...xs);
|
||||
const minY = Math.min(...ys);
|
||||
const maxY = Math.max(...ys);
|
||||
const clampedDx = Math.max(-minX, Math.min(1 - maxX, nextDx));
|
||||
const clampedDy = Math.max(-minY, Math.min(1 - maxY, nextDy));
|
||||
|
||||
return {
|
||||
x1: round3(clamp01(points.x1 + clampedDx)),
|
||||
y1: round3(clamp01(points.y1 + clampedDy)),
|
||||
xm: round3(clamp01(points.xm + clampedDx)),
|
||||
ym: round3(clamp01(points.ym + clampedDy)),
|
||||
x2: round3(clamp01(points.x2 + clampedDx)),
|
||||
y2: round3(clamp01(points.y2 + clampedDy)),
|
||||
};
|
||||
}
|
||||
|
||||
export function measureAngleDegrees(x1, y1, xm, ym, x2, y2) {
|
||||
const ax = Number(x1) - Number(xm);
|
||||
const ay = Number(y1) - Number(ym);
|
||||
const bx = Number(x2) - Number(xm);
|
||||
const by = Number(y2) - Number(ym);
|
||||
const lenA = Math.hypot(ax, ay);
|
||||
const lenB = Math.hypot(bx, by);
|
||||
|
||||
if (lenA <= 1e-12 || lenB <= 1e-12) return 0;
|
||||
|
||||
const cosTheta = ((ax * bx) + (ay * by)) / (lenA * lenB);
|
||||
const clamped = Math.max(-1, Math.min(1, cosTheta));
|
||||
return Math.acos(clamped) * (180 / Math.PI);
|
||||
}
|
||||
@@ -932,6 +932,105 @@ html, body, #root {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.angle-overlay {
|
||||
--angle-line-color: #ff0000;
|
||||
--angle-arc-color: rgb(255, 107, 107);
|
||||
--angle-end-handle-color: #ff0000;
|
||||
--angle-mid-handle-color: rgb(255, 184, 184);
|
||||
--angle-badge-text-color: rgb(255, 184, 184);
|
||||
--angle-badge-border-color: rgb(255, 82, 82);
|
||||
--angle-line-thickness: 1.35;
|
||||
--angle-arc-thickness: 1.05;
|
||||
position: relative;
|
||||
overflow: hidden;
|
||||
user-select: none;
|
||||
touch-action: none;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--border-default);
|
||||
border-radius: 6px;
|
||||
}
|
||||
|
||||
.angle-image {
|
||||
width: 100%;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.angle-svg {
|
||||
position: absolute;
|
||||
inset: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.angle-line {
|
||||
stroke: var(--angle-line-color);
|
||||
stroke-width: var(--angle-line-thickness);
|
||||
stroke-linecap: round;
|
||||
}
|
||||
|
||||
.angle-arc {
|
||||
fill: none;
|
||||
stroke: var(--angle-arc-color);
|
||||
stroke-width: var(--angle-arc-thickness);
|
||||
stroke-dasharray: 5 3;
|
||||
opacity: 0.95;
|
||||
}
|
||||
|
||||
.angle-handle {
|
||||
position: absolute;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 9px;
|
||||
font-weight: 700;
|
||||
color: var(--bg-deep);
|
||||
cursor: grab;
|
||||
box-shadow: 0 0 6px var(--marker-shadow);
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.angle-handle:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.angle-handle-end {
|
||||
background: var(--angle-end-handle-color);
|
||||
border: 1px solid var(--marker-border);
|
||||
}
|
||||
|
||||
.angle-handle-mid {
|
||||
width: 18px;
|
||||
height: 18px;
|
||||
background: var(--angle-mid-handle-color);
|
||||
border: 2px solid var(--marker-border);
|
||||
}
|
||||
|
||||
.angle-badge {
|
||||
position: absolute;
|
||||
transform: translate(-50%, -50%);
|
||||
padding: 3px 7px;
|
||||
border-radius: 999px;
|
||||
background: rgba(15, 23, 42, 0.9);
|
||||
border: 1px solid var(--angle-badge-border-color);
|
||||
color: var(--angle-badge-text-color);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.01em;
|
||||
box-shadow: 0 2px 8px rgba(15, 23, 42, 0.35);
|
||||
cursor: grab;
|
||||
user-select: none;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.angle-badge:active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.lineplot-overlay {
|
||||
width: 100%;
|
||||
aspect-ratio: 32 / 22;
|
||||
|
||||
62
frontend/tests/angleMeasureGeometry.test.mjs
Normal file
62
frontend/tests/angleMeasureGeometry.test.mjs
Normal file
@@ -0,0 +1,62 @@
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
import {
|
||||
getAngleLabelBasePosition,
|
||||
getAngleLabelPosition,
|
||||
measureAngleDegrees,
|
||||
moveAngleWidget,
|
||||
} from '../src/angleMeasureGeometry.js';
|
||||
|
||||
test('measureAngleDegrees returns the included angle', () => {
|
||||
assert.equal(measureAngleDegrees(0, 1, 0, 0, 1, 0), 90);
|
||||
assert.ok(Math.abs(measureAngleDegrees(-1, 0, 0, 0, 1, 0) - 180) < 1e-9);
|
||||
});
|
||||
|
||||
test('moveAngleWidget translates all points together', () => {
|
||||
const moved = moveAngleWidget(
|
||||
{ x1: 0.2, y1: 0.7, xm: 0.5, ym: 0.5, x2: 0.8, y2: 0.3 },
|
||||
0.1,
|
||||
-0.2,
|
||||
);
|
||||
|
||||
assert.deepEqual(moved, {
|
||||
x1: 0.3,
|
||||
y1: 0.5,
|
||||
xm: 0.6,
|
||||
ym: 0.3,
|
||||
x2: 0.9,
|
||||
y2: 0.1,
|
||||
});
|
||||
});
|
||||
|
||||
test('moveAngleWidget clamps whole-widget drags to the image bounds', () => {
|
||||
const moved = moveAngleWidget(
|
||||
{ x1: 0.2, y1: 0.7, xm: 0.5, ym: 0.5, x2: 0.8, y2: 0.3 },
|
||||
0.4,
|
||||
-0.5,
|
||||
);
|
||||
|
||||
assert.deepEqual(moved, {
|
||||
x1: 0.4,
|
||||
y1: 0.4,
|
||||
xm: 0.7,
|
||||
ym: 0.2,
|
||||
x2: 1,
|
||||
y2: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('getAngleLabelPosition follows the angle bisector and applies draggable offsets', () => {
|
||||
const base = getAngleLabelBasePosition(0.2, 0.5, 0.5, 0.5, 0.5, 0.2);
|
||||
assert.ok(base.x < 0.5);
|
||||
assert.ok(base.y < 0.5);
|
||||
|
||||
const shifted = getAngleLabelPosition(
|
||||
{ x1: 0.2, y1: 0.5, xm: 0.5, ym: 0.5, x2: 0.5, y2: 0.2 },
|
||||
-0.1,
|
||||
0.05,
|
||||
);
|
||||
assert.ok(Math.abs(shifted.x - (base.x - 0.1)) < 1e-9);
|
||||
assert.ok(Math.abs(shifted.y - (base.y + 0.05)) < 1e-9);
|
||||
});
|
||||
@@ -695,6 +695,91 @@ def test_scar_removal():
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
def test_angle_measure():
|
||||
print("=== Test: AngleMeasure ===")
|
||||
from backend.node_registry import get_node_info
|
||||
from backend.nodes.angle_measure import AngleMeasure
|
||||
from backend.data_types import ImageData
|
||||
|
||||
node = AngleMeasure()
|
||||
assert get_node_info("AngleMeasure")["category"] == "Overlay"
|
||||
required_inputs = AngleMeasure.INPUT_TYPES()["required"]
|
||||
assert required_inputs["color"][1]["default"] == "#ff0000"
|
||||
|
||||
field = make_field(
|
||||
data=np.zeros((32, 64), dtype=np.float64),
|
||||
xreal=4.0,
|
||||
yreal=2.0,
|
||||
)
|
||||
output, table = node.process(
|
||||
field,
|
||||
color="#c62828",
|
||||
line_thickness=1.8,
|
||||
x1=0.2,
|
||||
y1=0.5,
|
||||
xm=0.5,
|
||||
ym=0.5,
|
||||
x2=0.5,
|
||||
y2=0.2,
|
||||
label_dx=0.0,
|
||||
label_dy=0.0,
|
||||
)
|
||||
rows = {row["quantity"]: row for row in table}
|
||||
assert isinstance(output, DataField)
|
||||
assert output is not field
|
||||
assert len(output.overlays) == len(field.overlays) + 1
|
||||
assert output.overlays[-1]["kind"] == "angle_measure"
|
||||
assert output.overlays[-1]["color"] == "#c62828"
|
||||
assert np.isclose(output.overlays[-1]["line_thickness"], 1.8)
|
||||
assert np.isclose(rows["Arm A length"]["value"], 1.2)
|
||||
assert np.isclose(rows["Arm B length"]["value"], 0.6)
|
||||
assert np.isclose(rows["Angle"]["value"], 90.0)
|
||||
assert rows["Angle"]["unit"] == "deg"
|
||||
assert rows["Vertex x"]["unit"] == field.si_unit_xy
|
||||
|
||||
overridden_output, _ = node.process(
|
||||
field,
|
||||
color="not-a-color",
|
||||
line_thickness=0.7,
|
||||
x1=0.2,
|
||||
y1=0.5,
|
||||
xm=0.5,
|
||||
ym=0.5,
|
||||
x2=0.5,
|
||||
y2=0.2,
|
||||
label_dx=0.0,
|
||||
label_dy=0.0,
|
||||
line_thickness_input=2.4,
|
||||
)
|
||||
assert overridden_output.overlays[-1]["color"] == "#ff0000"
|
||||
assert np.isclose(overridden_output.overlays[-1]["line_thickness"], 2.4)
|
||||
|
||||
image = np.zeros((50, 100, 3), dtype=np.uint8)
|
||||
image_output, image_table = node.process(
|
||||
image,
|
||||
color="#ff0000",
|
||||
line_thickness=1.25,
|
||||
x1=0.25,
|
||||
y1=0.5,
|
||||
xm=0.5,
|
||||
ym=0.5,
|
||||
x2=0.5,
|
||||
y2=0.25,
|
||||
label_dx=0.0,
|
||||
label_dy=0.0,
|
||||
)
|
||||
image_rows = {row["quantity"]: row for row in image_table}
|
||||
assert isinstance(image_output, ImageData)
|
||||
assert image_output.shape == image.shape
|
||||
assert np.count_nonzero(np.asarray(image_output)) > 0
|
||||
assert np.isclose(image_rows["Arm A length"]["value"], 24.75)
|
||||
assert np.isclose(image_rows["Arm B length"]["value"], 12.25)
|
||||
assert np.isclose(image_rows["Angle"]["value"], 90.0)
|
||||
assert image_rows["Arm A length"]["unit"] == "px"
|
||||
|
||||
print(" PASS\n")
|
||||
|
||||
|
||||
# =========================================================================
|
||||
# Analysis (non-FFT)
|
||||
# =========================================================================
|
||||
@@ -2648,6 +2733,7 @@ if __name__ == "__main__":
|
||||
test_fix_zero()
|
||||
test_line_correction()
|
||||
test_scar_removal()
|
||||
test_angle_measure()
|
||||
|
||||
# Analysis
|
||||
test_statistics()
|
||||
|
||||
Reference in New Issue
Block a user