diff --git a/rayforge/camera/controller.py b/rayforge/camera/controller.py index ee2aa0bca..42bd5c5ff 100644 --- a/rayforge/camera/controller.py +++ b/rayforge/camera/controller.py @@ -10,6 +10,18 @@ from ..image.util.srgb import resize_linear_nd from ..shared.util.glib import idle_add from .models.camera import Camera +from .outside_view import ( + LensModel, + OutsideView, + expand_area, + frame_coverage_extent, + lens_coverage_extent, + lens_remap_maps, + native_output_size, + remap_transparent, + to_opaque_bgra, + warp_transparent, +) from .source import ( create_camera_source, list_local_device_ids, @@ -70,6 +82,10 @@ def __init__(self, config: Camera): self._active_source: CameraSource | None = None self._last_source_key = self._source_key() self._disposed: bool = False + # Cached outside view coverage and remap tables, which only + # change with calibration, alignment, and display geometry. + # Accessed from the GLib main loop only, like its callers. + self._outside_cache: dict[str, tuple] = {} # Stream lifecycle: _lifecycle_lock serializes start/stop so that # at most one capture thread can ever own the device at a time @@ -103,7 +119,7 @@ def __init__(self, config: Camera): self.resolutions_probed = Signal() self.config.changed.connect(self._on_config_changed) - self.config.settings_changed.connect(self._on_config_changed) + self.config.settings_changed.connect(self._on_capture_settings_changed) def _source_key(self) -> tuple: """A tuple identifying which physical source is being captured. @@ -119,9 +135,8 @@ def _on_config_changed(self, sender): """Reacts to changes in the data model.""" if self._disposed: return - self._settings_dirty = True - with self._frame_lock: - self._accumulator = None # Reset smoothing if settings change + if not self.config.outside_view_enabled: + self._outside_cache.clear() new_source_key = self._source_key() source_changed = new_source_key != self._last_source_key self._last_source_key = new_source_key @@ -136,6 +151,14 @@ def _on_config_changed(self, sender): # Also stop if it's disabled, regardless of subscribers self._stop_capture_stream() + def _on_capture_settings_changed(self, sender): + """Reacts to changes that affect capturing or frame processing.""" + if self._disposed: + return + self._settings_dirty = True + with self._frame_lock: + self._accumulator = None # Reset smoothing if settings change + @staticmethod def list_available_devices() -> list[str]: """ @@ -346,6 +369,66 @@ def get_work_surface_image( logger.warning("No image data available.") return None + return self._work_surface_from_image(image, output_size, physical_area) + + def get_work_surface_images( + self, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + outside_margin_mm: float, + max_dimension: int, + ) -> tuple[np.ndarray | None, OutsideView | None]: + """ + Get the workspace image and the surrounding outside view from a + single frame. + + The workspace image is identical to what + :meth:`get_work_surface_image` returns for the same arguments. The + outside view covers ``physical_area`` expanded by the effective + margin on all four sides, which is ``outside_margin_mm`` clamped to + the area the camera actually sees. It is rendered at the camera's + pixel density, capped to ``max_dimension``, independent of + ``output_size``. + + With lens correction, the outside view is sampled from the raw + frame captured together with the processed one, since lens + correction crops the edges of the processed frame. + + Returns: + A tuple (workspace image, outside view). The outside view is + None without alignment, without coverage beyond the workspace, + or on failure; the workspace image is None on failure. + """ + with self._frame_lock: + image = self._image_data + raw = self._raw_image_data + + if image is None: + logger.warning("No image data available.") + return None, None + + workspace = self._work_surface_from_image( + image, output_size, physical_area + ) + try: + outside = self._outside_view_from_image( + image, + raw, + physical_area, + outside_margin_mm, + max_dimension, + ) + except (cv2.error, ValueError, np.linalg.LinAlgError) as e: + logger.error(f"Failed to render outside camera view: {e}") + outside = None + return workspace, outside + + def _work_surface_from_image( + self, + image: np.ndarray, + output_size: tuple[int, int], + physical_area: tuple[Pos, Pos], + ) -> np.ndarray | None: if self.config.image_to_world is not None: return self._transform_with_homography( image, output_size, physical_area @@ -418,6 +501,91 @@ def _transform_with_homography( logger.error(f"Failed to apply perspective warp: {e}") return None + def _outside_view_from_image( + self, + image: np.ndarray, + raw: np.ndarray | None, + physical_area: tuple[Pos, Pos], + margin_mm: float, + max_dimension: int, + ) -> OutsideView | None: + if self.config.image_to_world is None or margin_mm <= 0: + return None + H = self._compute_homography(image.shape[0]) + lens = self._lens_model(image) + if lens is not None and raw is not None: + return self._outside_view_from_raw( + raw, + H, + lens, + physical_area, + margin_mm, + max_dimension, + ) + + margin = min( + margin_mm, + frame_coverage_extent(H, image.shape, physical_area), + ) + if margin <= 0: + return None + area = expand_area(physical_area, margin) + size = native_output_size(H, physical_area, margin, max_dimension) + warped = warp_transparent(to_opaque_bgra(image), H, size, area) + return OutsideView(image=warped, area=area, margin_mm=margin) + + def _outside_view_from_raw( + self, + raw: np.ndarray, + H: np.ndarray, + lens: LensModel, + physical_area: tuple[Pos, Pos], + margin_mm: float, + max_dimension: int, + ) -> OutsideView | None: + """ + Render the outside view from the raw frame, so that content lens + correction pushed out of the corrected frame is still shown. + """ + base_key = (H.tobytes(), lens.key(), raw.shape[:2], physical_area) + + extent_key = (base_key, margin_mm) + cached = self._outside_cache.get("extent") + if cached is not None and cached[0] == extent_key: + margin = cached[1] + else: + margin = lens_coverage_extent( + H, lens, raw.shape, physical_area, margin_mm + ) + self._outside_cache["extent"] = (extent_key, margin) + if margin <= 0: + return None + + area = expand_area(physical_area, margin) + size = native_output_size(H, physical_area, margin, max_dimension) + maps_key = (base_key, area, size) + cached = self._outside_cache.get("maps") + if cached is not None and cached[0] == maps_key: + maps = cached[1] + else: + maps = lens_remap_maps(H, lens, size, area) + self._outside_cache["maps"] = (maps_key, maps) + + remapped = remap_transparent(to_opaque_bgra(raw), maps) + return OutsideView(image=remapped, area=area, margin_mm=margin) + + def _lens_model(self, image: np.ndarray) -> LensModel | None: + """The lens correction applied to frames of this image's size.""" + cam_mat, dist = self._get_effective_calibration( + image.shape[0], image.shape[1] + ) + if cam_mat is None or dist is None: + return None + return LensModel( + camera_matrix=np.asarray(cam_mat, dtype=np.float64), + distortion=np.asarray(dist, dtype=np.float64), + ) + def _apply_settings(self, source) -> None: """Applies the current settings to the VideoCapture object.""" try: @@ -545,6 +713,7 @@ def _handle_frame_failure(self): self._consecutive_failures += 1 with self._frame_lock: self._image_data = None + self._raw_image_data = None return self._consecutive_failures >= self.MAX_CONSECUTIVE_FAILURES def _log_frame_failure(self, source) -> None: @@ -754,9 +923,10 @@ def _stop_locked(self): self._capture_thread = None def _clear_image_data(self) -> None: - """Drops the processed frame under the frame lock.""" + """Drops the captured frames under the frame lock.""" with self._frame_lock: self._image_data = None + self._raw_image_data = None def capture_image(self, *, apply_settings: bool = True): """ diff --git a/rayforge/camera/models/camera.py b/rayforge/camera/models/camera.py index 10f961868..601dbff2b 100644 --- a/rayforge/camera/models/camera.py +++ b/rayforge/camera/models/camera.py @@ -1,5 +1,6 @@ import json import logging +import math import uuid from collections.abc import Sequence from datetime import datetime, timezone @@ -15,6 +16,8 @@ Pos = tuple[float, float] PointList = Sequence[Pos] +OUTSIDE_VIEW_MAX_MARGIN_MM = 100.0 + def _as_utc(dt: datetime | None) -> datetime | None: """Interpret a possibly naive datetime as UTC. @@ -60,6 +63,12 @@ def __init__( self._prefer_yuyv: bool = False + # Optional camera view around the workspace, drawn on the work + # surface only outside the workspace rectangle. + self._outside_view_enabled: bool = False + self._outside_view_margin_mm: float = 30.0 + self._outside_view_transparency: float = 0.65 + self._resolution: tuple[int, int] | None = None # Lens calibration parameters @@ -319,6 +328,79 @@ def prefer_yuyv(self, value: bool): self.changed.send(self) self.settings_changed.send(self) + @property + def outside_view_enabled(self) -> bool: + """Whether to show the camera image around the workspace.""" + return self._outside_view_enabled + + @outside_view_enabled.setter + def outside_view_enabled(self, value: bool): + if not isinstance(value, bool): + raise TypeError("outside_view_enabled must be a boolean.") + if self._outside_view_enabled == value: + return + logger.debug( + f"Camera outside_view_enabled changed from " + f"{self._outside_view_enabled} to {value}" + ) + self._outside_view_enabled = value + self.changed.send(self) + + @property + def outside_view_margin_mm(self) -> float: + """Margin shown on each side of the workspace, in mm.""" + return self._outside_view_margin_mm + + @outside_view_margin_mm.setter + def outside_view_margin_mm(self, value: float): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("Outside view margin must be a number.") + if not math.isfinite(value): + raise ValueError("Outside view margin must be finite.") + if not (0.0 <= value <= OUTSIDE_VIEW_MAX_MARGIN_MM): + logger.warning( + f"Outside view margin {value} is outside range " + f"(0.0-{OUTSIDE_VIEW_MAX_MARGIN_MM}). " + "Clamping to nearest bound." + ) + value = max(0.0, min(value, OUTSIDE_VIEW_MAX_MARGIN_MM)) + value = float(value) + if self._outside_view_margin_mm == value: + return + logger.debug( + f"Camera outside_view_margin_mm changed from " + f"{self._outside_view_margin_mm} to {value}" + ) + self._outside_view_margin_mm = value + self.changed.send(self) + + @property + def outside_view_transparency(self) -> float: + """Visibility of the outside view; higher is more visible.""" + return self._outside_view_transparency + + @outside_view_transparency.setter + def outside_view_transparency(self, value: float): + if not isinstance(value, (int, float)) or isinstance(value, bool): + raise TypeError("Outside view transparency must be a number.") + if not math.isfinite(value): + raise ValueError("Outside view transparency must be finite.") + if not (0.0 <= value <= 1.0): + logger.warning( + f"Outside view transparency {value} is outside range " + "(0.0-1.0). Clamping to nearest bound." + ) + value = max(0.0, min(value, 1.0)) + value = float(value) + if self._outside_view_transparency == value: + return + logger.debug( + f"Camera outside_view_transparency changed from " + f"{self._outside_view_transparency} to {value}" + ) + self._outside_view_transparency = value + self.changed.send(self) + @property def resolution(self) -> tuple[int, int] | None: return self._resolution @@ -656,6 +738,9 @@ def to_dict(self) -> dict[str, Any]: "transparency": self.transparency, "denoise": self.denoise, "prefer_yuyv": self.prefer_yuyv, + "outside_view_enabled": self.outside_view_enabled, + "outside_view_margin_mm": self.outside_view_margin_mm, + "outside_view_transparency": self.outside_view_transparency, "distortion_k1": self.distortion_k1, "distortion_k2": self.distortion_k2, "distortion_p1": self.distortion_p1, @@ -725,6 +810,9 @@ def from_dict(cls, data: dict[str, Any]) -> "Camera": "transparency", "denoise", "prefer_yuyv", + "outside_view_enabled", + "outside_view_margin_mm", + "outside_view_transparency", "image_to_world", "distortion_k1", "distortion_k2", @@ -768,6 +856,15 @@ def from_dict(cls, data: dict[str, Any]) -> "Camera": camera.transparency = data.get("transparency", camera.transparency) camera.denoise = data.get("denoise", 0.0) camera.prefer_yuyv = data.get("prefer_yuyv", False) + camera.outside_view_enabled = data.get( + "outside_view_enabled", camera.outside_view_enabled + ) + camera.outside_view_margin_mm = data.get( + "outside_view_margin_mm", camera.outside_view_margin_mm + ) + camera.outside_view_transparency = data.get( + "outside_view_transparency", camera.outside_view_transparency + ) camera.distortion_k1 = data.get("distortion_k1", 0.0) camera.distortion_k2 = data.get("distortion_k2", 0.0) diff --git a/rayforge/camera/outside_view.py b/rayforge/camera/outside_view.py new file mode 100644 index 000000000..f02a0a929 --- /dev/null +++ b/rayforge/camera/outside_view.py @@ -0,0 +1,321 @@ +""" +Geometry for the optional camera view outside the workspace. + +The workspace image is warped from the lens-corrected frame. Lens +correction keeps the frame size, so a strong barrel correction pushes +the frame edges out of the corrected image and drops them. The outside +view therefore samples the raw frame instead: every output pixel is +mapped from world coordinates through the alignment homography into the +lens-corrected image, and from there through the lens distortion model +into the raw frame. Without lens correction the corrected and raw frames +coincide and a plain perspective warp is used. +""" + +import math +from dataclasses import dataclass + +import cv2 +import numpy as np + +Pos = tuple[float, float] +Area = tuple[Pos, Pos] + +# Upper bound for the normalized undistorted radius searched for the +# limit of the distortion model. +_RADIAL_SEARCH_MAX = 4.0 +_RADIAL_SEARCH_STEPS = 4000 +# Keep away from the radius where the distortion model folds back. +_RADIAL_SAFETY = 0.98 +# Number of grid samples per axis used to measure lens coverage. +_COVERAGE_SAMPLES = 512 + + +@dataclass(frozen=True) +class OutsideView: + """The camera image around the workspace, for display only. + + Attributes: + image: Premultiplied BGRA image covering ``area``; pixels without + camera coverage are fully transparent. + area: Physical area ((x_min, y_min), (x_max, y_max)) in mm. + margin_mm: The effective margin around the workspace, in mm. + """ + + image: np.ndarray + area: Area + margin_mm: float + + +@dataclass(frozen=True) +class LensModel: + """The lens correction applied to a frame, in OpenCV conventions.""" + + camera_matrix: np.ndarray + distortion: np.ndarray + + def key(self) -> tuple: + return ( + np.asarray(self.camera_matrix, dtype=np.float64).tobytes(), + np.asarray(self.distortion, dtype=np.float64).tobytes(), + ) + + +def expand_area(area: Area, margin: float) -> Area: + (x_min, y_min), (x_max, y_max) = area + return (x_min - margin, y_min - margin), (x_max + margin, y_max + margin) + + +def native_output_size( + H: np.ndarray, + physical_area: Area, + margin_mm: float, + max_dimension: int, +) -> tuple[int, int]: + """ + Size an expanded output at the camera's pixel density. + + The density is measured along the workspace edges in the corrected + frame, using the sharper of both axes, since rendering finer than the + camera resolves adds no detail. It depends only on the alignment, not + on the display size, so resizing the window does not change it. + """ + (x_min, y_min), (x_max, y_max) = physical_area + corners = ( + np.array( + [ + [x_min, y_min, 1], + [x_max, y_min, 1], + [x_max, y_max, 1], + [x_min, y_max, 1], + ], + dtype=np.float64, + ) + @ H.T + ) + width_mm = x_max - x_min + height_mm = y_max - y_min + density = 0.0 + if np.all(corners[:, 2] > 0): + px = corners[:, :2] / corners[:, 2:3] + edges = np.linalg.norm(px - np.roll(px, -1, axis=0), axis=1) + density = max( + (edges[0] + edges[2]) / (2 * width_mm), + (edges[1] + edges[3]) / (2 * height_mm), + ) + width = width_mm + 2 * margin_mm + height = height_mm + 2 * margin_mm + if not math.isfinite(density) or density <= 0: + density = max_dimension / max(width, height) + width *= density + height *= density + largest = max(width, height) + if largest > max_dimension: + width *= max_dimension / largest + height *= max_dimension / largest + return max(1, round(width)), max(1, round(height)) + + +def output_to_world(output_size: tuple[int, int], area: Area) -> np.ndarray: + """The matrix mapping output pixels to world coordinates (Y-up).""" + (x_min, y_min), (x_max, y_max) = area + width_px, height_px = output_size + return np.array( + [ + [(x_max - x_min) / width_px, 0, x_min], + [0, -(y_max - y_min) / height_px, y_max], + [0, 0, 1], + ], + dtype=np.float64, + ) + + +def _distance_beyond(xs: np.ndarray, ys: np.ndarray, area: Area) -> float: + """The largest distance by which points lie outside an area.""" + if xs.size == 0: + return 0.0 + (x_min, y_min), (x_max, y_max) = area + extent = max( + x_min - xs.min(), + xs.max() - x_max, + y_min - ys.min(), + ys.max() - y_max, + ) + return max(0.0, float(extent)) + + +def frame_coverage_extent( + H: np.ndarray, frame_shape: tuple[int, ...], physical_area: Area +) -> float: + """ + How far, in mm, an undistorted frame reaches beyond the given area. + + Projects the corners of the frame into world coordinates and returns + the largest distance by which they extend past any side of + ``physical_area``, or 0 if they do not. A corner at or beyond the + horizon of the plane counts as unbounded. + """ + height, width = frame_shape[:2] + corners = np.array( + [[0, 0, 1], [width, 0, 1], [width, height, 1], [0, height, 1]], + dtype=np.float64, + ) + projected = corners @ np.linalg.inv(H).T + w = projected[:, 2] + if np.any(w <= 0) or not np.all(np.isfinite(projected)): + return math.inf + return _distance_beyond( + projected[:, 0] / w, projected[:, 1] / w, physical_area + ) + + +def radial_limit(distortion: np.ndarray) -> float: + """ + The largest normalized undistorted radius the radial distortion model + maps one-to-one. + + Beyond this radius r * (1 + k1 r^2 + k2 r^4 + k3 r^6) stops growing, + so the model folds back and would mirror image content. + """ + k1, k2, _p1, _p2, k3 = (list(np.ravel(distortion)) + [0.0] * 5)[:5] + r = np.linspace(0.0, _RADIAL_SEARCH_MAX, _RADIAL_SEARCH_STEPS) + r2 = r * r + slope = 1 + 3 * k1 * r2 + 5 * k2 * r2**2 + 7 * k3 * r2**3 + folded = np.nonzero(slope <= 0)[0] + if folded.size == 0: + return math.inf + return float(r[folded[0]]) * _RADIAL_SAFETY + + +def world_to_raw( + H: np.ndarray, + lens: LensModel, + xs: np.ndarray, + ys: np.ndarray, +) -> tuple[np.ndarray, np.ndarray, np.ndarray]: + """ + Map world points to raw frame pixel coordinates. + + The homography gives the position in the lens-corrected frame, which + is then distorted with the lens model, exactly inverting the + correction applied by ``cv2.undistort`` with the same camera matrix. + + Returns: + Raw x and y pixel coordinates, and a mask of points for which the + mapping is valid (in front of the camera and within the range + the distortion model maps one-to-one). + """ + u = H[0, 0] * xs + H[0, 1] * ys + H[0, 2] + v = H[1, 0] * xs + H[1, 1] * ys + H[1, 2] + w = H[2, 0] * xs + H[2, 1] * ys + H[2, 2] + valid = w > 1e-12 + w = np.where(valid, w, 1.0) + u = u / w + v = v / w + + K = lens.camera_matrix + fx, fy, cx, cy = K[0, 0], K[1, 1], K[0, 2], K[1, 2] + k1, k2, p1, p2, k3 = (list(np.ravel(lens.distortion)) + [0.0] * 5)[:5] + x = (u - cx) / fx + y = (v - cy) / fy + r2 = x * x + y * y + limit = radial_limit(lens.distortion) + valid &= r2 < limit * limit + radial = 1 + k1 * r2 + k2 * r2 * r2 + k3 * r2 * r2 * r2 + xd = x * radial + 2 * p1 * x * y + p2 * (r2 + 2 * x * x) + yd = y * radial + p1 * (r2 + 2 * y * y) + 2 * p2 * x * y + return fx * xd + cx, fy * yd + cy, valid + + +def lens_coverage_extent( + H: np.ndarray, + lens: LensModel, + frame_shape: tuple[int, ...], + physical_area: Area, + margin_mm: float, +) -> float: + """ + How far, in mm and up to ``margin_mm``, the raw frame reaches beyond + the given area. + + Samples the expanded area on a grid, maps every sample into the raw + frame and measures the valid samples that land inside it. + """ + (x_min, y_min), (x_max, y_max) = expand_area(physical_area, margin_mm) + xs, ys = np.meshgrid( + np.linspace(x_min, x_max, _COVERAGE_SAMPLES), + np.linspace(y_min, y_max, _COVERAGE_SAMPLES), + ) + raw_x, raw_y, valid = world_to_raw(H, lens, xs, ys) + height, width = frame_shape[:2] + valid &= (raw_x >= 0) & (raw_x <= width - 1) + valid &= (raw_y >= 0) & (raw_y <= height - 1) + extent = _distance_beyond(xs[valid], ys[valid], physical_area) + return min(extent, margin_mm) + + +def lens_remap_maps( + H: np.ndarray, + lens: LensModel, + output_size: tuple[int, int], + area: Area, +) -> tuple[np.ndarray, np.ndarray]: + """ + Build ``cv2.remap`` maps sampling the raw frame for an output area. + + Output pixels without a valid mapping point outside the frame, so + they come out transparent. + """ + width_px, height_px = output_size + cols, rows = np.meshgrid( + np.arange(width_px, dtype=np.float64), + np.arange(height_px, dtype=np.float64), + ) + T = output_to_world(output_size, area) + xs = T[0, 0] * cols + T[0, 2] + ys = T[1, 1] * rows + T[1, 2] + raw_x, raw_y, valid = world_to_raw(H, lens, xs, ys) + raw_x = np.where(valid, raw_x, -1.0).astype(np.float32) + raw_y = np.where(valid, raw_y, -1.0).astype(np.float32) + return cv2.convertMaps(raw_x, raw_y, cv2.CV_16SC2) + + +def to_opaque_bgra(image: np.ndarray) -> np.ndarray: + """An opaque BGRA copy of a frame.""" + bgr = np.ascontiguousarray(image[:, :, :3]) + return cv2.cvtColor(bgr, cv2.COLOR_BGR2BGRA) + + +def remap_transparent( + image: np.ndarray, maps: tuple[np.ndarray, np.ndarray] +) -> np.ndarray: + """ + Remap an opaque BGRA image, transparent where there is no source. + + Interpolating an opaque image against a transparent border yields + premultiplied alpha, including partial edge pixels. + """ + return cv2.remap( + image, + maps[0], + maps[1], + cv2.INTER_LINEAR, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0, 0), + ) + + +def warp_transparent( + image: np.ndarray, + H: np.ndarray, + output_size: tuple[int, int], + area: Area, +) -> np.ndarray: + """Warp an opaque BGRA image to an area, transparent elsewhere.""" + M = H @ output_to_world(output_size, area) + return cv2.warpPerspective( + image, + np.linalg.inv(M), + output_size, + borderMode=cv2.BORDER_CONSTANT, + borderValue=(0, 0, 0, 0), + ) diff --git a/rayforge/locale/de/LC_MESSAGES/rayforge.po b/rayforge/locale/de/LC_MESSAGES/rayforge.po index f1e9c9098..48ddf3172 100644 --- a/rayforge/locale/de/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/de/LC_MESSAGES/rayforge.po @@ -10471,6 +10471,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Ansicht außerhalb des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Das Kamerabild in einem Rand um den Arbeitsbereich anzeigen, um Werkstücke zu positionieren. Erfordert eine Bildausrichtung." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Außerhalb des Arbeitsbereichs anzeigen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Das Kamerabild um den Arbeitsbereich herum zeichnen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Wird erst angezeigt, wenn das Kamerabild ausgerichtet ist" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Rand" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Größe des sichtbaren Bereichs auf jeder Seite des Arbeitsbereichs" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparenz außerhalb des Arbeitsbereichs" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/en/LC_MESSAGES/rayforge.po b/rayforge/locale/en/LC_MESSAGES/rayforge.po index 03df88562..5a6d86ab4 100644 --- a/rayforge/locale/en/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/en/LC_MESSAGES/rayforge.po @@ -9829,6 +9829,38 @@ msgstr "" msgid "Bayer 8" msgstr "" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/es/LC_MESSAGES/rayforge.po b/rayforge/locale/es/LC_MESSAGES/rayforge.po index 295d8b2d1..4c232f46c 100644 --- a/rayforge/locale/es/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/es/LC_MESSAGES/rayforge.po @@ -10445,6 +10445,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Muestra la imagen de la cámara en un margen alrededor del área de trabajo para ayudar a posicionar las piezas. Requiere la alineación de la imagen." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fuera del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dibujar la imagen de la cámara alrededor del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "No se muestra hasta que la imagen de la cámara esté alineada" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margen" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamaño del área visible a cada lado del área de trabajo" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparencia fuera del área de trabajo" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/fr/LC_MESSAGES/rayforge.po b/rayforge/locale/fr/LC_MESSAGES/rayforge.po index d310e1822..1fbc25f58 100644 --- a/rayforge/locale/fr/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/fr/LC_MESSAGES/rayforge.po @@ -10492,6 +10492,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vue hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Affiche l’image de la caméra dans une marge autour de la zone de travail pour aider à positionner les pièces. Nécessite l’alignement de l’image." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Afficher hors de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Dessiner l’image de la caméra autour de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Non affichée tant que l’image de la caméra n’est pas alignée" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Marge" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Taille de la zone visible de chaque côté de la zone de travail" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparence hors de la zone de travail" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/hi/LC_MESSAGES/rayforge.po b/rayforge/locale/hi/LC_MESSAGES/rayforge.po index 79fbf06e6..30c5c8753 100644 --- a/rayforge/locale/hi/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/hi/LC_MESSAGES/rayforge.po @@ -10151,6 +10151,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर का दृश्य" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "वर्कपीस को स्थित करने में मदद के लिए कार्य क्षेत्र के चारों ओर एक मार्जिन में कैमरा छवि दिखाएँ। छवि संरेखण आवश्यक है।" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "कार्य क्षेत्र के बाहर दिखाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "कार्य क्षेत्र के चारों ओर कैमरा छवि बनाएँ" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "कैमरा छवि संरेखित होने तक नहीं दिखाई जाती" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "मार्जिन" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "कार्य क्षेत्र के प्रत्येक ओर दृश्य क्षेत्र का आकार" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "कार्य क्षेत्र के बाहर पारदर्शिता" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/pt/LC_MESSAGES/rayforge.po b/rayforge/locale/pt/LC_MESSAGES/rayforge.po index a2e033f03..b1e251f3d 100644 --- a/rayforge/locale/pt/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/pt/LC_MESSAGES/rayforge.po @@ -10397,6 +10397,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Vista fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Mostra a imagem da câmera em uma margem ao redor da área de trabalho para ajudar a posicionar as peças. Requer o alinhamento da imagem." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Mostrar fora da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Desenhar a imagem da câmera ao redor da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Não é mostrada até que a imagem da câmera esteja alinhada" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Margem" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Tamanho da área visível em cada lado da área de trabalho" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Transparência fora da área de trabalho" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/rayforge.pot b/rayforge/locale/rayforge.pot index 3b3c56a45..d19d7adb8 100644 --- a/rayforge/locale/rayforge.pot +++ b/rayforge/locale/rayforge.pot @@ -9830,6 +9830,38 @@ msgstr "" msgid "Bayer 8" msgstr "" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/uk/LC_MESSAGES/rayforge.po b/rayforge/locale/uk/LC_MESSAGES/rayforge.po index cf3aa3340..2b275f418 100644 --- a/rayforge/locale/uk/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/uk/LC_MESSAGES/rayforge.po @@ -10357,6 +10357,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "Вигляд поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "Показувати зображення камери в полі навколо робочої області, щоб допомогти розташувати заготовки. Потребує вирівнювання зображення." + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "Показувати поза робочою областю" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "Малювати зображення камери навколо робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "Не показується, доки зображення камери не вирівняно" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "Поле" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "Розмір видимої області з кожного боку робочої області" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "Прозорість поза робочою областю" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po index f3c03fa97..1e133c37b 100644 --- a/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po +++ b/rayforge/locale/zh_CN/LC_MESSAGES/rayforge.po @@ -9958,6 +9958,38 @@ msgstr "Bayer 4" msgid "Bayer 8" msgstr "Bayer 8" +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "View Outside Workspace" +msgstr "工作区域外视图" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show the camera image in a margin around the work area to help position workpieces. Requires image alignment." +msgstr "在工作区域周围的边距中显示相机图像,以帮助定位工件。需要图像对齐。" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Show Outside Workspace" +msgstr "显示工作区域外部" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Draw the camera image around the work area" +msgstr "在工作区域周围绘制相机图像" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Not shown until the camera image is aligned" +msgstr "相机图像对齐后才会显示" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Margin" +msgstr "边距" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Size of the visible area on each side of the work area" +msgstr "工作区域每侧可见区域的大小" + +#: rayforge/ui_gtk/camera/outside_view_widget.py +msgid "Transparency outside the work area" +msgstr "工作区域外的透明度" + #: rayforge/image/bitmap/importer.py #, python-brace-format msgid "Could not read the {format} image: {error}" diff --git a/rayforge/ui_gtk/camera/camera_preferences_page.py b/rayforge/ui_gtk/camera/camera_preferences_page.py index ccbd530a8..5483a69dd 100644 --- a/rayforge/ui_gtk/camera/camera_preferences_page.py +++ b/rayforge/ui_gtk/camera/camera_preferences_page.py @@ -11,6 +11,7 @@ from ..shared.preferences_group import PreferencesGroupWithButton from ..shared.preferences_page import TrackedPreferencesPage from ..shared.slider import create_slider +from .outside_view_widget import CameraOutsideViewGroup from .properties_widget import CameraProperties from .selection_dialog import CameraSelectionDialog @@ -342,6 +343,10 @@ def __init__(self, **kwargs): self.camera_properties_widget = CameraProperties(None) self.add(self.camera_properties_widget) + # Display of the camera image around the workspace. + self.camera_outside_view_widget = CameraOutsideViewGroup() + self.add(self.camera_outside_view_widget) + # Connect signals self.camera_list_editor.add_requested.connect(self.on_add_camera) self.camera_list_editor.remove_requested.connect(self.on_remove_camera) @@ -421,6 +426,8 @@ def on_camera_selected(self, listbox, row): ) self.selected_controller = selected_controller self.camera_properties_widget.set_controller(selected_controller) + self.camera_outside_view_widget.set_controller(selected_controller) else: self.selected_controller = None self.camera_properties_widget.set_controller(None) + self.camera_outside_view_widget.set_controller(None) diff --git a/rayforge/ui_gtk/camera/outside_view_widget.py b/rayforge/ui_gtk/camera/outside_view_widget.py new file mode 100644 index 000000000..33b753e9d --- /dev/null +++ b/rayforge/ui_gtk/camera/outside_view_widget.py @@ -0,0 +1,131 @@ +import logging +from gettext import gettext as _ + +from gi.repository import Adw, Gtk + +from ...camera.controller import CameraController +from ...camera.models.camera import OUTSIDE_VIEW_MAX_MARGIN_MM, Camera +from ..shared.pref_rows import LengthSpinRow +from ..shared.slider import create_slider_row + +logger = logging.getLogger(__name__) + + +class CameraOutsideViewGroup(Adw.PreferencesGroup): + """Settings for showing the camera image around the workspace.""" + + def __init__(self, **kwargs): + super().__init__( + title=_("View Outside Workspace"), + description=_( + "Show the camera image in a margin around the work area " + "to help position workpieces. Requires image alignment." + ), + **kwargs, + ) + self._camera: Camera | None = None + self._updating_ui: bool = False + + self.enabled_row = Adw.ActionRow( + title=_("Show Outside Workspace"), + subtitle=_("Draw the camera image around the work area"), + ) + self.enabled_switch = Gtk.Switch() + self.enabled_switch.set_valign(Gtk.Align.CENTER) + self.enabled_switch.connect("notify::active", self._on_enabled_changed) + self.enabled_row.add_suffix(self.enabled_switch) + self.enabled_row.set_activatable_widget(self.enabled_switch) + self.add(self.enabled_row) + + self.margin_row = LengthSpinRow( + _("Margin"), + _("Size of the visible area on each side of the work area"), + lower=0.0, + upper=OUTSIDE_VIEW_MAX_MARGIN_MM, + step_increment=1.0, + digits=0, + numeric=True, + ) + self.margin_row.value_changed.connect(self._on_margin_changed) + self.add(self.margin_row) + + self.transparency_adjustment = Gtk.Adjustment( + value=0.0, + lower=0.0, + upper=1.0, + step_increment=0.01, + page_increment=0.1, + ) + self.transparency_row, self.transparency_scale = create_slider_row( + title=_("Transparency"), + subtitle=_("Transparency outside the work area"), + adjustment=self.transparency_adjustment, + digits=2, + on_value_changed=self._on_transparency_changed, + ) + self.add(self.transparency_row) + + self.set_controller(None) + + def set_controller(self, controller: CameraController | None): + if self._camera: + self._camera.changed.disconnect(self._on_camera_changed) + + self._camera = controller.config if controller else None + + if self._camera: + self._camera.changed.connect(self._on_camera_changed) + self.set_sensitive(True) + else: + self.set_sensitive(False) + self.update_ui() + + def update_ui(self): + if self._updating_ui: + return + self._updating_ui = True + try: + camera = self._camera + enabled = bool(camera and camera.outside_view_enabled) + self.enabled_switch.set_active(enabled) + self.margin_row.set_sensitive(enabled) + self.transparency_row.set_sensitive(enabled) + if camera: + self.margin_row.set_value_in_base_units( + camera.outside_view_margin_mm + ) + self.transparency_adjustment.set_value( + camera.outside_view_transparency + ) + self._update_alignment_hint() + finally: + self._updating_ui = False + + def _update_alignment_hint(self): + camera = self._camera + if camera and not camera.has_alignment: + self.enabled_row.set_subtitle( + _("Not shown until the camera image is aligned") + ) + else: + self.enabled_row.set_subtitle( + _("Draw the camera image around the work area") + ) + + def _on_camera_changed(self, camera, *args): + self.update_ui() + + def _on_enabled_changed(self, switch, _pspec): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_enabled = switch.get_active() + + def _on_margin_changed(self, row): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_margin_mm = row.get_value_in_base_units() + + def _on_transparency_changed(self, scale): + if self._updating_ui or not self._camera: + return + self._camera.outside_view_transparency = scale.get_value() diff --git a/rayforge/ui_gtk/canvas2d/elements/camera_image.py b/rayforge/ui_gtk/canvas2d/elements/camera_image.py index bc31df060..6dcee9a32 100644 --- a/rayforge/ui_gtk/canvas2d/elements/camera_image.py +++ b/rayforge/ui_gtk/canvas2d/elements/camera_image.py @@ -9,6 +9,7 @@ from gi.repository import GLib from ....camera.controller import CameraController +from ....camera.outside_view import OutsideView from ...canvas import CanvasElement if TYPE_CHECKING: @@ -22,18 +23,62 @@ MAX_PROCESSING_DIMENSION = 2048 +def _processing_size(output_size: tuple[int, int]) -> tuple[int, int]: + """Caps an output size to MAX_PROCESSING_DIMENSION.""" + processing_width, processing_height = output_size + if max(processing_width, processing_height) > MAX_PROCESSING_DIMENSION: + scale = MAX_PROCESSING_DIMENSION / max( + processing_width, processing_height + ) + processing_width = round(processing_width * scale) + processing_height = round(processing_height * scale) + return processing_width, processing_height + + +def _surface_for_data( + data: np.ndarray, +) -> tuple[cairo.ImageSurface, np.ndarray]: + """Wraps a BGRA buffer in a Cairo surface, returning both.""" + height, width, _ = data.shape + surface_data = np.ascontiguousarray(data) + surface = cairo.ImageSurface.create_for_data( + surface_data, # type: ignore + cairo.FORMAT_ARGB32, + width, + height, # type: ignore + ) + return surface, surface_data + + class CameraImageElement(CanvasElement): - def __init__(self, controller: CameraController, **kwargs): + def __init__( + self, + controller: CameraController, + outside_view_supported: bool = False, + **kwargs, + ): # We are not a standard buffered element because we manage our own # surface cache to prevent flicker. We will use a custom draw() method. + # With outside view support the element draws beyond its bounds, so + # it clips the workspace image itself instead. super().__init__( - x=0, y=0, width=1.0, height=1.0, buffered=False, **kwargs + x=0, + y=0, + width=1.0, + height=1.0, + buffered=False, + clip=not outside_view_supported, + **kwargs, ) self.selectable = False + self.outside_view_supported = outside_view_supported self.controller = controller self.camera = controller.config # Convenience alias for the data model self.controller.image_captured.connect(self._on_state_changed) self.camera.changed.connect(self._on_camera_model_changed) + # Display-only settings (e.g. the outside view) emit `changed` + # only, so both signals must invalidate the cache. + self.camera.changed.connect(self._on_state_changed) self.camera.settings_changed.connect(self._on_state_changed) self.set_visible(self.camera.enabled) @@ -42,6 +87,13 @@ def __init__(self, controller: CameraController, **kwargs): self._cached_surface_data: np.ndarray | None = None # A key representing the state that generated the cached surface. self._cached_key: tuple | None = None + # The key of the processing job that is scheduled but not yet run. + self._pending_key: tuple | None = None + # Cache for the optional view outside the workspace: its surface, + # data buffer, view, and the outside signature it was made for. + self._outside_cache: ( + tuple[cairo.ImageSurface, np.ndarray, OutsideView, tuple] | None + ) = None def _on_camera_model_changed(self, sender): """ @@ -67,6 +119,7 @@ def remove(self): """ self.controller.image_captured.disconnect(self._on_state_changed) self.camera.changed.disconnect(self._on_camera_model_changed) + self.camera.changed.disconnect(self._on_state_changed) self.camera.settings_changed.disconnect(self._on_state_changed) super().remove() @@ -95,11 +148,17 @@ def draw(self, ctx: cairo.Context): bounds, and triggers a recomputation if the camera state has changed. """ assert self.canvas, "Canvas must be set before drawing" - worksurface = cast("WorkSurface", self.canvas) + outside = self._valid_outside_cache() # 1. Draw the last valid computed surface to prevent flicker. if self._cached_surface: ctx.save() + if not self.clip: + if outside: + # Pixel-exact edges keep the two passes seamless. + ctx.set_antialias(cairo.ANTIALIAS_NONE) + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() source_w = self._cached_surface.get_width() source_h = self._cached_surface.get_height() @@ -131,51 +190,141 @@ def draw(self, ctx: cairo.Context): ctx.restore() + if outside: + self._draw_outside(ctx, outside[0], outside[2]) + # 2. Check if a new surface needs to be computed. - # The output size for the recomputation should be the pixel dimensions - # of the canvas widget itself, not the mm dimensions of the work area. - output_width = self.canvas.get_width() - output_height = self.canvas.get_height() + current_key = self._current_key() + if current_key is None: + return + # 3. Recompute if needed, but in a non-blocking way. if ( - self.controller.image_data is None - or output_width <= 0 - or output_height <= 0 + self._cached_key != current_key + and self._pending_key != current_key ): - return + self._pending_key = current_key + GLib.idle_add(self._process_and_update_cache, current_key) - physical_area = None - if self.camera.image_to_world: - physical_area = ( - (0, 0), - (worksurface.width_mm, worksurface.height_mm), - ) + def _physical_area(self) -> tuple | None: + """The workspace area to align to, or None without alignment.""" + if not self.camera.image_to_world: + return None + worksurface = cast("WorkSurface", self.canvas) + return ((0, 0), (worksurface.width_mm, worksurface.height_mm)) - current_key = ( - id(self.controller.image_data), + def _outside_margin_request(self) -> float | None: + """The outside view margin to render, or None to skip the pass.""" + camera = self.camera + if ( + not self.outside_view_supported + or not camera.outside_view_enabled + or not camera.image_to_world + or camera.outside_view_margin_mm <= 0 + or camera.outside_view_transparency <= 0 + ): + return None + return camera.outside_view_margin_mm + + def _outside_signature(self) -> tuple | None: + """Identifies the geometry an outside view is valid for.""" + margin = self._outside_margin_request() + if margin is None: + return None + return (self.camera.image_to_world, self._physical_area(), margin) + + def _valid_outside_cache(self) -> tuple | None: + """The cached outside view, if it matches the current settings.""" + if self._outside_cache is None: + return None + signature = self._outside_signature() + if signature is None or self._outside_cache[3] != signature: + return None + return self._outside_cache + + def _current_key(self) -> tuple | None: + """A key for the state the cached surfaces should reflect.""" + if not self.canvas: + return None + # The output size for the recomputation should be the pixel + # dimensions of the canvas widget itself, not the mm dimensions of + # the work area. + output_width = self.canvas.get_width() + output_height = self.canvas.get_height() + image_data = self.controller.image_data + + if image_data is None or output_width <= 0 or output_height <= 0: + return None + + return ( + id(image_data), output_width, output_height, - physical_area, + self._physical_area(), self.camera.transparency, + self._outside_margin_request(), ) - # 3. Recompute if needed, but in a non-blocking way. - if self._cached_key != current_key: - GLib.idle_add(self._process_and_update_cache, current_key) + def _draw_outside( + self, + ctx: cairo.Context, + surface: cairo.ImageSurface, + view: OutsideView, + ): + """ + Paints the outside view around, but never inside, the workspace, + at the outside view transparency. + """ + (x_min, y_min), (x_max, y_max) = view.area + area_w = x_max - x_min + area_h = y_max - y_min + source_w = surface.get_width() + source_h = surface.get_height() + if area_w <= 0 or area_h <= 0 or source_w <= 0 or source_h <= 0: + return + + ctx.save() + ctx.set_antialias(cairo.ANTIALIAS_NONE) + ctx.set_fill_rule(cairo.FILL_RULE_EVEN_ODD) + ctx.rectangle(x_min, y_min, area_w, area_h) + ctx.rectangle(0, 0, self.width, self.height) + ctx.clip() + + ctx.translate(x_min, y_min) + ctx.scale(area_w / source_w, area_h / source_h) + # The world is Y-up, but the cairo surface is Y-down. + ctx.translate(0, source_h) + ctx.scale(1, -1) + + ctx.set_source_surface(surface, 0, 0) + ctx.get_source().set_filter(cairo.FILTER_GOOD) + ctx.paint_with_alpha(self.camera.outside_view_transparency) + ctx.restore() def _process_and_update_cache(self, key_for_this_job: tuple) -> bool: """The actual work, to be run by GLib.idle_add.""" - image_data = self.controller.image_data - img_data_id, width, height, p_area, transp = key_for_this_job + if self._pending_key == key_for_this_job: + self._pending_key = None - if image_data is None or id(image_data) != img_data_id: - # A newer frame has already arrived; this job is stale. + if key_for_this_job != self._current_key(): + # A newer frame or a settings change has arrived, or the + # element was removed; this job is stale. return False # Stop the idle add - # Generate both the surface and its data buffer. - result = self._generate_surface( - image_data, (width, height), p_area, transp - ) + image_data = self.controller.image_data + assert image_data is not None + _, width, height, p_area, transp, margin = key_for_this_job + + outside = None + if p_area is not None and margin is not None: + result, outside = self._generate_surfaces( + (width, height), p_area, transp, margin + ) + else: + # Generate both the surface and its data buffer. + result = self._generate_surface( + image_data, (width, height), p_area, transp + ) if result: new_surface, new_surface_data = result @@ -183,12 +332,42 @@ def _process_and_update_cache(self, key_for_this_job: tuple) -> bool: self._cached_surface = new_surface self._cached_surface_data = new_surface_data self._cached_key = key_for_this_job + self._outside_cache = outside if self.canvas: self.canvas.queue_draw() # This function should only run once per schedule. return False + def _generate_surfaces( + self, + output_size: tuple[int, int], + physical_area: tuple, + transparency: float, + margin_mm: float, + ) -> tuple[tuple | None, tuple | None]: + """ + Creates the workspace surface and the outside view surface from a + single frame. The outside result also carries the view and the + signature it is valid for. + """ + signature = self._outside_signature() + workspace, view = self.controller.get_work_surface_images( + output_size=_processing_size(output_size), + physical_area=physical_area, + outside_margin_mm=margin_mm, + max_dimension=MAX_PROCESSING_DIMENSION, + ) + if workspace is None: + logger.warning("Image transformation failed, skipping frame.") + return None, None + + outside = None + if view is not None and signature is not None: + surface, data = _surface_for_data(view.image) + outside = (surface, data, view, signature) + return self._surface_from_image(workspace, transparency), outside + def _generate_surface( self, image_data: np.ndarray, @@ -203,19 +382,8 @@ def _generate_surface( processed_image = image_data if physical_area: - processing_width, processing_height = output_size - if ( - max(processing_width, processing_height) - > MAX_PROCESSING_DIMENSION - ): - scale = MAX_PROCESSING_DIMENSION / max( - processing_width, processing_height - ) - processing_width = round(processing_width * scale) - processing_height = round(processing_height * scale) - transformed_image = self.controller.get_work_surface_image( - output_size=(processing_width, processing_height), + output_size=_processing_size(output_size), physical_area=physical_area, ) @@ -224,6 +392,15 @@ def _generate_surface( return None processed_image = transformed_image + return self._surface_from_image(processed_image, transparency) + + def _surface_from_image( + self, processed_image: np.ndarray, transparency: float + ) -> tuple[cairo.ImageSurface, np.ndarray]: + """ + Applies the camera opacity to an image and creates a Cairo surface, + returning it along with its data buffer. + """ if processed_image.shape[2] == 3: bgra_image = cv2.cvtColor(processed_image, cv2.COLOR_BGR2BGRA) else: diff --git a/rayforge/ui_gtk/canvas2d/surface.py b/rayforge/ui_gtk/canvas2d/surface.py index afbe2e0c9..94a6ac8d5 100644 --- a/rayforge/ui_gtk/canvas2d/surface.py +++ b/rayforge/ui_gtk/canvas2d/surface.py @@ -1262,7 +1262,9 @@ def set_camera_controllers(self, controllers: list[CameraController]): # Add elements for new controllers for controller in new_controllers - current_controllers: - element = CameraImageElement(controller) + element = CameraImageElement( + controller, outside_view_supported=True + ) element.set_visible( self._cam_visible and controller.config.enabled ) diff --git a/scripts/screenshot/cli.py b/scripts/screenshot/cli.py index 880a53fd8..98dae1bf2 100755 --- a/scripts/screenshot/cli.py +++ b/scripts/screenshot/cli.py @@ -79,6 +79,7 @@ "main:3d": "main_3d", "main:3d-bee": "main_3d_bee", "main:3d-rotary": "main_3d_rotary", + "main:camera-outside-view": "main_camera_outside_view", "main:array:grid": "array_grid", "main:array:point-rotation": "array_point_rotation", "main:array:circular": "array_circular", diff --git a/scripts/screenshot/machine_settings_camera.py b/scripts/screenshot/machine_settings_camera.py index 093a5e4e4..96450504d 100644 --- a/scripts/screenshot/machine_settings_camera.py +++ b/scripts/screenshot/machine_settings_camera.py @@ -3,12 +3,12 @@ import logging import subprocess import time -from pathlib import Path -import cv2 -from gi.repository import GLib +from gi.repository import GLib, Gtk from utils import ( + align_mock_camera, get_target, + inject_mock_image, open_machine_settings, restore_config, run_on_main_thread, @@ -32,14 +32,6 @@ PAGE = "camera" TARGET = get_target(f"machine-settings:{PAGE}") -MOCK_IMAGE_PATH = ( - Path(__file__).parent.parent.parent - / "website" - / "static" - / "images" - / "work-surface.png" -) - WIZARD_PAGES = ("card", "capture") DIALOGS = { @@ -70,30 +62,6 @@ def parse_target(target: str) -> dict | None: return None -def load_mock_image(): - """Load the mock camera image as a BGR numpy array.""" - img = cv2.imread(str(MOCK_IMAGE_PATH), cv2.IMREAD_COLOR) - if img is None: - logger.error("Failed to load mock image from %s", MOCK_IMAGE_PATH) - return None - logger.info("Loaded mock image: %s (%s)", MOCK_IMAGE_PATH, img.shape) - return img - - -def inject_mock_image(controller): - """Inject mock image data into the controller and trigger redraw.""" - img = load_mock_image() - if img is None: - return False - controller._raw_image_data = img.copy() - controller._image_data = img.copy() - GLib.idle_add(controller.image_captured.send, controller) - # Prevent the real capture loop from starting (mock device can't open) - controller._running = True - time.sleep(0.25) - return True - - def add_mock_camera(dialog): """Add a mock camera, select it in the list, and inject mock image.""" machine = dialog.machine @@ -140,6 +108,47 @@ def _has_camera(): return None, controller +def _iter_descendants(widget): + """Yield widget and all of its descendants, depth-first.""" + child = widget.get_first_child() + while child: + yield child + yield from _iter_descendants(child) + child = child.get_next_sibling() + + +def _scroll_camera_page_to_bottom(dialog): + """Scroll the camera page so its bottommost group is visible. + + The value is pinned repeatedly rather than set once, because the + page scrolls smoothly and a single set is only captured in-flight. + """ + + def _find_adjustment(): + for widget in _iter_descendants(dialog.camera_page): + if isinstance(widget, Gtk.ScrolledWindow): + adjustment = widget.get_vadjustment() + if adjustment.get_upper() > adjustment.get_page_size(): + return adjustment + return None + + def _scroll(): + adjustment = _find_adjustment() + if adjustment is None: + logger.warning("No scroll adjustment found for the camera page") + return + remaining = [30] + + def _pin(): + adjustment.set_value(adjustment.get_upper()) + remaining[0] -= 1 + return remaining[0] > 0 + + GLib.timeout_add(50, _pin) + + run_on_main_thread(_scroll) + + def take_wizard_screenshot(parent_dialog, wizard_page: str): """Open the lens calibration wizard directly and take a screenshot.""" @@ -208,8 +217,15 @@ def main(): time.sleep(0.25) if target_info is None: - setup_camera_page(dialog) - time.sleep(0.25) + camera, controller = setup_camera_page(dialog) + camera = camera or (controller.config if controller else None) + if camera is not None: + width_mm, height_mm = dialog.machine.axis_extents + align_mock_camera(camera, width_mm, height_mm) + # Show the outside-workspace settings in their enabled state. + camera.outside_view_enabled = True + _scroll_camera_page_to_bottom(dialog) + time.sleep(1.8) take_screenshot() time.sleep(0.25) app.quit_idle() diff --git a/scripts/screenshot/main_camera_outside_view.py b/scripts/screenshot/main_camera_outside_view.py new file mode 100644 index 000000000..a31014f25 --- /dev/null +++ b/scripts/screenshot/main_camera_outside_view.py @@ -0,0 +1,95 @@ +""" +Screenshot: Main window with the camera view outside the workspace. + +Usage: pixi run screenshot main:camera-outside-view +""" + +import logging +import time + +from gi.repository import GLib +from utils import ( + align_mock_camera, + clear_window_subtitle, + inject_mock_image, + restore_config, + run_on_main_thread, + set_window_size, + take_screenshot, + wait_for_settled, +) + +from rayforge.camera.models.camera import Camera +from rayforge.context import get_context +from rayforge.ui_gtk.canvas2d.elements.camera_image import CameraImageElement +from rayforge.uiscript import app, win + +logger = logging.getLogger(__name__) + + +@restore_config +def main(): + set_window_size(win, 2400, 1650) + + machine = get_context().config.machine + width_mm, height_mm = machine.axis_extents + + camera = Camera("Test Camera", "mock-device-0") + camera.enabled = True + camera.transparency = 0.65 + align_mock_camera(camera, width_mm, height_mm) + camera.outside_view_margin_mm = 30 + camera.outside_view_enabled = True + + def _add_camera(): + machine.add_camera(camera) + return camera + + run_on_main_thread(_add_camera) + time.sleep(0.5) + + controller = get_context().camera_mgr.get_controller(camera.id) + if not controller: + logger.error("Failed to create mock camera controller") + app.quit_idle() + return + + def _sync_elements(): + # The work surface only picks up new cameras on machine changes, + # which can race the creation of the controller. Re-sync now that + # the controller definitely exists. + win.surface._sync_camera_elements() + + run_on_main_thread(_sync_elements) + inject_mock_image(controller) + + def _show_camera_overlay(): + action = win.action_manager.get_action("toggle_camera_view") + action.change_state(GLib.Variant.new_boolean(True)) + # Zoom out so the margin around the workspace is on screen. + win.surface.set_pan(0.0, 0.0) + win.surface.set_zoom(0.72) + + run_on_main_thread(_show_camera_overlay) + time.sleep(1.0) + + def _check(): + elems = list(win.surface.find_by_type(CameraImageElement)) + if not elems or not elems[0].visible: + logger.error("Camera element is not visible on the work surface") + return False + + GLib.timeout_add(500, _check) + if not wait_for_settled(win, timeout=10): + logger.error("Document did not settle in time") + app.quit_idle() + return + + clear_window_subtitle(win) + logger.info("Taking screenshot: main-camera-outside-view.webp") + take_screenshot() + time.sleep(0.25) + app.quit_idle() + + +main() diff --git a/scripts/screenshot/utils.py b/scripts/screenshot/utils.py index 027356476..bbf677ae0 100644 --- a/scripts/screenshot/utils.py +++ b/scripts/screenshot/utils.py @@ -26,6 +26,7 @@ TypeVar, ) +import cv2 import numpy as np import pyvips from gi.repository import Adw, GdkX11, GLib, Gtk @@ -1086,6 +1087,58 @@ def _load() -> None: logger.info(f"Loaded project: {project_name}") +MOCK_IMAGE_PATH = ( + PROJECT_ROOT / "website" / "static" / "images" / "work-surface.png" +) + +# The region of the mock image that maps onto the machine bed. The +# surrounding image content becomes the camera view outside the +# workspace. +MOCK_BED_REGION = (120, 60, 520, 420) + + +def load_mock_image() -> "np.ndarray | None": + """Load the mock camera image as a BGR numpy array.""" + img = cv2.imread(str(MOCK_IMAGE_PATH), cv2.IMREAD_COLOR) + if img is None: + logger.error("Failed to load mock image from %s", MOCK_IMAGE_PATH) + return None + logger.info("Loaded mock image: %s (%s)", MOCK_IMAGE_PATH, img.shape) + return img + + +def inject_mock_image(controller) -> bool: + """Inject mock image data into the controller and trigger redraw.""" + img = load_mock_image() + if img is None: + return False + controller._raw_image_data = img.copy() + controller._image_data = img.copy() + GLib.idle_add(controller.image_captured.send, controller) + # Prevent the real capture loop from starting (mock device can't open) + controller._running = True + time.sleep(0.25) + return True + + +def align_mock_camera(camera, bed_width_mm: float, bed_height_mm: float): + """Align a mock camera so the mock image covers the whole bed. + + Maps an inset region of the mock image onto the bed rectangle, so + the remaining image content shows up outside the workspace. + """ + x0, y0, x1, y1 = MOCK_BED_REGION + camera.image_to_world = ( + [(x0, y0), (x1, y0), (x1, y1), (x0, y1)], + [ + (0, bed_height_mm), + (bed_width_mm, bed_height_mm), + (bed_width_mm, 0), + (0, 0), + ], + ) + + def set_window_size( win: "MainWindow", width: int, height: int, timeout: float = 1.0 ) -> bool: diff --git a/tests/camera/test_camera.py b/tests/camera/test_camera.py index 7b54bb528..e31866edb 100644 --- a/tests/camera/test_camera.py +++ b/tests/camera/test_camera.py @@ -489,3 +489,87 @@ def test_from_dict_naive_dates_become_aware(): assert camera._alignment_date is not None assert camera._alignment_date.tzinfo is not None assert camera.alignment_valid is True + + +def test_outside_view_defaults(): + camera = Camera("Test Camera", "0") + assert camera.outside_view_enabled is False + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + + +def test_outside_view_old_config_loads_with_defaults(): + camera = Camera.from_dict({"name": "Old Camera", "device_id": "0"}) + assert camera.outside_view_enabled is False + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + assert "outside_view_enabled" not in camera.extra + + +def test_outside_view_round_trip(): + camera = Camera("Test Camera", "0") + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 42.5 + camera.outside_view_transparency = 0.3 + + restored = Camera.from_json(camera.to_json()) + + assert restored.outside_view_enabled is True + assert restored.outside_view_margin_mm == 42.5 + assert restored.outside_view_transparency == 0.3 + assert not any(k.startswith("outside_view") for k in restored.extra) + + +def test_outside_view_values_are_clamped(): + camera = Camera("Test Camera", "0") + camera.outside_view_margin_mm = 150 + assert camera.outside_view_margin_mm == 100.0 + camera.outside_view_margin_mm = -5 + assert camera.outside_view_margin_mm == 0.0 + camera.outside_view_transparency = 1.5 + assert camera.outside_view_transparency == 1.0 + camera.outside_view_transparency = -0.5 + assert camera.outside_view_transparency == 0.0 + + +@pytest.mark.parametrize("value", [float("nan"), float("inf"), -float("inf")]) +def test_outside_view_rejects_nonfinite(value): + camera = Camera("Test Camera", "0") + with pytest.raises(ValueError): + camera.outside_view_margin_mm = value + with pytest.raises(ValueError): + camera.outside_view_transparency = value + assert camera.outside_view_margin_mm == 30.0 + assert camera.outside_view_transparency == 0.65 + + +def test_outside_view_rejects_wrong_types(): + camera = Camera("Test Camera", "0") + with pytest.raises(TypeError): + camera.outside_view_enabled = 1 # type: ignore[assignment] + with pytest.raises(TypeError): + camera.outside_view_margin_mm = "10" # type: ignore[assignment] + with pytest.raises(TypeError): + camera.outside_view_transparency = None # type: ignore[assignment] + + +def test_outside_view_setters_emit_signals(): + camera = Camera("Test Camera", "0") + changed = [] + settings_changed = [] + camera.changed.connect(changed.append, weak=False) + camera.settings_changed.connect(settings_changed.append, weak=False) + + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 10 + camera.outside_view_transparency = 0.5 + assert len(changed) == 3 + # The outside view is display-only; it must not invalidate capture + # or processing settings. + assert len(settings_changed) == 0 + + camera.outside_view_enabled = True + camera.outside_view_margin_mm = 10.0 + camera.outside_view_transparency = 0.5 + assert len(changed) == 3 + assert len(settings_changed) == 0 diff --git a/tests/camera/test_controller.py b/tests/camera/test_controller.py index 5103717c9..aaf1f77be 100644 --- a/tests/camera/test_controller.py +++ b/tests/camera/test_controller.py @@ -12,6 +12,15 @@ from rayforge.camera.controller import CameraController from rayforge.camera.models.camera import Camera, CameraSourceType +from rayforge.camera.outside_view import ( + LensModel, + frame_coverage_extent, + lens_remap_maps, + radial_limit, + to_opaque_bgra, + warp_transparent, + world_to_raw, +) from rayforge.camera.source import ( CameraSource, HttpSnapshotSource, @@ -693,6 +702,29 @@ def fake_start(): assert calls == ["start"] +def test_outside_view_change_does_not_dirty_capture_settings(): + """Outside view settings are display-only: they must not re-apply + hardware settings or reset the denoising accumulator.""" + camera = Camera("Test Camera", "0") + camera.denoise = 0.5 + controller = CameraController(camera) + controller._settings_dirty = False + with controller._frame_lock: + controller._accumulator = np.zeros((4, 4, 3), dtype=np.float32) + + camera.outside_view_margin_mm = 60 + camera.outside_view_transparency = 0.3 + camera.outside_view_enabled = True + + assert controller._settings_dirty is False + assert controller._accumulator is not None + + camera.contrast = 2.0 + + assert controller._settings_dirty is True + assert controller._accumulator is None + + def test_local_device_source_sets_read_timeout_when_supported(): camera = Camera("Local Camera", "0") set_calls = [] @@ -1066,6 +1098,30 @@ def join(self, timeout=None): assert controller._running is False +def test_frame_failure_clears_both_frame_buffers(): + """The raw frame must not outlive the processed one, or the paired + render API could combine frames from different points in time.""" + camera = Camera("Test Camera", "0") + controller = CameraController(camera) + with controller._frame_lock: + controller._image_data = np.zeros((4, 4, 3), dtype=np.uint8) + controller._raw_image_data = np.zeros((4, 4, 3), dtype=np.uint8) + + controller._handle_frame_failure() + + assert controller._image_data is None + assert controller._raw_image_data is None + + with controller._frame_lock: + controller._image_data = np.zeros((4, 4, 3), dtype=np.uint8) + controller._raw_image_data = np.zeros((4, 4, 3), dtype=np.uint8) + + controller._clear_image_data() + + assert controller._image_data is None + assert controller._raw_image_data is None + + def test_dispose_disconnects_config_signals_and_stops_stream(): """dispose() must fully detach the controller from its camera config so a config mutation after teardown can never resurrect it, and must @@ -1108,3 +1164,342 @@ def test_subscribe_after_dispose_is_a_noop(): assert start_calls == [] assert controller._active_subscribers == 0 + + +def _aligned_gradient_controller() -> CameraController: + """An aligned controller whose frame encodes pixel positions. + + The alignment maps 4 px/mm in x and 3 px/mm in y, so the 640x480 + frame covers world x in [-25, 135] and y in about [-33.3, 126.7]. + """ + camera_config = Camera("Test Camera", "0") + controller = CameraController(camera_config) + image = np.zeros((480, 640, 3), dtype=np.uint8) + image[:, :, 0] = (np.arange(640) * 255 // 639)[np.newaxis, :] + image[:, :, 1] = (np.arange(480) * 255 // 479)[:, np.newaxis] + controller._image_data = image + camera_config.image_to_world = ( + [(100, 100), (500, 100), (500, 400), (100, 400)], + [(0, 100), (100, 100), (100, 0), (0, 0)], + ) + return controller + + +WORKSPACE = ((0, 0), (100, 100)) + + +def test_get_work_surface_images_workspace_matches_single_render(): + controller = _aligned_gradient_controller() + + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + expected = controller.get_work_surface_image((200, 200), WORKSPACE) + assert workspace is not None and expected is not None + np.testing.assert_array_equal(workspace, expected) + assert outside is not None + + +def test_outside_view_area_size_and_world_coordinates(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert outside is not None + assert outside.margin_mm == 20 + assert outside.area == ((-20, -20), (120, 120)) + # Rendered at the camera's density of 4 px/mm, the sharper axis. + assert outside.image.shape == (560, 560, 4) + + # Every covered pixel, including all edges and corners at negative + # coordinates, matches the existing transform of the expanded area. + reference = controller.get_work_surface_image((560, 560), outside.area) + assert reference is not None + alpha = outside.image[:, :, 3] + covered = alpha == 255 + for row, col in [(0, 0), (0, -1), (-1, 0), (-1, -1), (280, 0)]: + assert covered[row, col] + np.testing.assert_array_equal( + outside.image[:, :, :3][covered], reference[covered] + ) + + +def test_outside_view_margin_is_clamped_to_camera_coverage(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + # The frame reaches 35 mm beyond the right edge of the workspace. + assert outside.margin_mm == pytest.approx(35.0) + assert controller.config.outside_view_margin_mm == 30.0 + + +def test_outside_view_missing_coverage_is_transparent(): + controller = _aligned_gradient_controller() + controller._image_data = np.zeros((480, 640, 3), dtype=np.uint8) + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + image = outside.image + # Left of x=-25 mm the camera sees nothing: fully transparent. + assert image[:, :10, 3].max() == 0 + # Genuine black pixels inside the frame stay opaque. + assert image[170, 170, 3] == 255 + np.testing.assert_array_equal(image[170, 170, :3], [0, 0, 0]) + + +def test_outside_view_is_premultiplied_with_partial_edges(): + controller = _aligned_gradient_controller() + controller._image_data = np.full((480, 640, 3), 255, dtype=np.uint8) + + # A non-integer density puts the frame edges between pixels. + _, outside = controller.get_work_surface_images( + (233, 233), WORKSPACE, 100, 2048 + ) + + assert outside is not None + alpha = outside.image[:, :, 3] + assert ((alpha > 0) & (alpha < 255)).any() + assert (outside.image[:, :, :3] <= alpha[:, :, np.newaxis]).all() + + +def test_outside_view_none_without_alignment_or_margin(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 0, 2048 + ) + assert outside is None + + controller.config.image_to_world = None + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + assert outside is None + assert workspace is not None + + +def test_outside_view_none_without_coverage_beyond_workspace(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), ((-50, -50), (150, 150)), 20, 2048 + ) + + assert outside is None + + +def test_outside_view_size_is_capped(): + controller = _aligned_gradient_controller() + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 400 + ) + + assert outside is not None + assert outside.image.shape[:2] == (400, 400) + + +def test_get_work_surface_images_uses_a_single_frame(): + controller = _aligned_gradient_controller() + controller._image_data = np.full((480, 640, 3), 200, dtype=np.uint8) + newer_frame = np.full((480, 640, 3), 50, dtype=np.uint8) + original = controller._work_surface_from_image + + def replace_frame_midway(*args): + result = original(*args) + with controller._frame_lock: + controller._image_data = newer_frame + return result + + with patch.object( + controller, "_work_surface_from_image", replace_frame_midway + ): + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert workspace is not None and outside is not None + assert (workspace == 200).all() + covered = outside.image[:, :, 3] == 255 + assert (outside.image[:, :, :3][covered] == 200).all() + + +def _aligned_lens_controller(k1: float = -0.3) -> CameraController: + """An aligned gradient controller with barrel lens correction. + + The raw frame is the gradient; the processed frame is its lens + correction, which pushes the frame edges out of the image. + """ + controller = _aligned_gradient_controller() + controller.config.distortion_k1 = k1 + raw = controller._image_data + assert raw is not None + controller._raw_image_data = raw + controller._image_data = controller._process_frame(raw) + return controller + + +def test_outside_view_recovers_content_cropped_by_lens_correction(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + H = controller._compute_homography(image.shape[0]) + corrected_extent = frame_coverage_extent(H, image.shape, WORKSPACE) + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + assert outside.margin_mm > corrected_extent + 5 + + +def test_outside_view_from_raw_matches_corrected_frame(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 100, 2048 + ) + + assert outside is not None + size = outside.image.shape[1::-1] + H = controller._compute_homography(image.shape[0]) + reference = warp_transparent(to_opaque_bgra(image), H, size, outside.area) + both = (reference[:, :, 3] == 255) & (outside.image[:, :, 3] == 255) + assert both.mean() > 0.3 + difference = np.abs( + reference[:, :, :3][both].astype(int) + - outside.image[:, :, :3][both].astype(int) + ) + assert difference.mean() < 2 + + +def test_world_to_raw_inverts_lens_correction(): + controller = _aligned_lens_controller() + image = controller._image_data + assert image is not None + lens = controller._lens_model(image) + assert lens is not None + corrected = np.array( + [[320.0, 240.0], [50.0, 60.0], [600.0, 420.0], [10.0, 470.0]] + ) + + raw_x, raw_y, valid = world_to_raw( + np.eye(3), lens, corrected[:, 0], corrected[:, 1] + ) + + assert valid.all() + raw = np.stack([raw_x, raw_y], axis=1).reshape(-1, 1, 2) + back = cv2.undistortPoints( + raw, lens.camera_matrix, lens.distortion, P=lens.camera_matrix + ).reshape(-1, 2) + np.testing.assert_allclose(back, corrected, atol=0.05) + + +def test_radial_limit_excludes_folding_distortion(): + assert radial_limit(np.zeros(5)) == float("inf") + + distortion = np.array([-0.98, 1.75, 0.0, 0.0, -1.75]) + limit = radial_limit(distortion) + + assert 0.6 < limit < 0.8 + lens = LensModel(np.eye(3), distortion) + _, _, valid = world_to_raw( + np.eye(3), + lens, + np.array([0.5 * limit, 1.1 * limit]), + np.zeros(2), + ) + np.testing.assert_array_equal(valid, [True, False]) + + +def test_outside_view_lens_maps_are_cached_across_frames(): + controller = _aligned_lens_controller() + + with patch( + "rayforge.camera.controller.lens_remap_maps", + wraps=lens_remap_maps, + ) as build_maps: + for value in (100, 150): + raw = np.full((480, 640, 3), value, dtype=np.uint8) + controller._raw_image_data = raw + controller._image_data = controller._process_frame(raw) + _, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 30, 2048 + ) + assert outside is not None + + controller.config.outside_view_margin_mm = 10 + controller.get_work_surface_images((200, 200), WORKSPACE, 10, 2048) + + assert build_maps.call_count == 2 + + +def test_get_work_surface_images_uses_a_single_raw_frame(): + controller = _aligned_lens_controller() + controller._raw_image_data = np.full((480, 640, 3), 200, np.uint8) + controller._image_data = np.full((480, 640, 3), 200, np.uint8) + newer_frame = np.full((480, 640, 3), 50, dtype=np.uint8) + original = controller._work_surface_from_image + + def replace_frames_midway(*args): + result = original(*args) + with controller._frame_lock: + controller._image_data = newer_frame + controller._raw_image_data = newer_frame + return result + + with patch.object( + controller, "_work_surface_from_image", replace_frames_midway + ): + workspace, outside = controller.get_work_surface_images( + (200, 200), WORKSPACE, 20, 2048 + ) + + assert workspace is not None and outside is not None + assert (workspace == 200).all() + covered = outside.image[:, :, 3] == 255 + assert covered.any() + assert (outside.image[:, :, :3][covered] == 200).all() + + +def test_outside_view_does_not_depend_on_display_size(): + controller = _aligned_lens_controller() + + with patch( + "rayforge.camera.controller.lens_remap_maps", + wraps=lens_remap_maps, + ) as build_maps: + shapes = set() + for output_size in [(200, 200), (800, 600), (1203, 777)]: + _, outside = controller.get_work_surface_images( + output_size, WORKSPACE, 20, 2048 + ) + assert outside is not None + shapes.add(outside.image.shape) + + assert len(shapes) == 1 + assert build_maps.call_count == 1 + + +def test_disabling_outside_view_frees_cached_maps(): + controller = _aligned_lens_controller() + controller.config.outside_view_enabled = True + controller.get_work_surface_images((200, 200), WORKSPACE, 20, 2048) + assert controller._outside_cache + + controller.config.outside_view_enabled = False + + assert not controller._outside_cache diff --git a/tests/ui_gtk/camera/test_outside_view_widget.py b/tests/ui_gtk/camera/test_outside_view_widget.py new file mode 100644 index 000000000..d951c85be --- /dev/null +++ b/tests/ui_gtk/camera/test_outside_view_widget.py @@ -0,0 +1,97 @@ +# flake8: noqa: E402 +"""UI tests for the camera outside view settings group.""" + +import gi + +gi.require_version("Gtk", "4.0") +gi.require_version("Adw", "1") + +import pytest + +from rayforge.camera.controller import CameraController +from rayforge.camera.models.camera import Camera +from rayforge.ui_gtk.camera.outside_view_widget import ( + CameraOutsideViewGroup, +) + +pytestmark = pytest.mark.ui + + +def _controller(name: str) -> CameraController: + return CameraController(Camera(name, "0")) + + +def test_controls_follow_toggle_and_preserve_values(ui_context_initializer): + controller = _controller("Camera") + camera = controller.config + camera.outside_view_margin_mm = 40 + camera.outside_view_transparency = 0.4 + group = CameraOutsideViewGroup() + group.set_controller(controller) + + assert not group.enabled_switch.get_active() + assert not group.margin_row.get_sensitive() + assert not group.transparency_row.get_sensitive() + assert group.margin_row.get_value_in_base_units() == 40 + assert group.transparency_adjustment.get_value() == 0.4 + + group.enabled_switch.set_active(True) + + assert camera.outside_view_enabled + assert group.margin_row.get_sensitive() + assert group.transparency_row.get_sensitive() + + group.enabled_switch.set_active(False) + assert camera.outside_view_margin_mm == 40 + assert camera.outside_view_transparency == 0.4 + + +def test_controls_write_to_the_camera(ui_context_initializer): + controller = _controller("Camera") + group = CameraOutsideViewGroup() + group.set_controller(controller) + group.enabled_switch.set_active(True) + + group.transparency_adjustment.set_value(0.25) + group.margin_row.get_spin_button().set_value(55) + + assert controller.config.outside_view_transparency == 0.25 + assert controller.config.outside_view_margin_mm == 55 + + +def test_switching_controllers_keeps_settings_independent( + ui_context_initializer, +): + first = _controller("First") + second = _controller("Second") + first.config.outside_view_enabled = True + first.config.outside_view_margin_mm = 70 + group = CameraOutsideViewGroup() + + group.set_controller(first) + group.set_controller(second) + + assert not group.enabled_switch.get_active() + assert group.margin_row.get_value_in_base_units() == 30 + assert second.config.outside_view_margin_mm == 30 + assert first.config.outside_view_margin_mm == 70 + + first.config.outside_view_margin_mm = 80 + assert group.margin_row.get_value_in_base_units() == 30 + + group.set_controller(None) + assert not group.get_sensitive() + + +def test_alignment_hint(ui_context_initializer): + controller = _controller("Camera") + group = CameraOutsideViewGroup() + group.set_controller(controller) + unaligned = group.enabled_row.get_subtitle() + + controller.config.image_to_world = ( + [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + [(0.0, 0.0), (1.0, 0.0), (1.0, 1.0), (0.0, 1.0)], + ) + + assert group.enabled_row.get_subtitle() != unaligned diff --git a/tests/ui_gtk/canvas2d/test_camera_image.py b/tests/ui_gtk/canvas2d/test_camera_image.py new file mode 100644 index 000000000..18b98366e --- /dev/null +++ b/tests/ui_gtk/canvas2d/test_camera_image.py @@ -0,0 +1,180 @@ +"""Tests for the camera image element's optional outside view.""" + +from unittest.mock import MagicMock, patch + +import cairo +import numpy as np +import pytest + +from rayforge.camera.controller import CameraController +from rayforge.camera.models.camera import Camera +from rayforge.ui_gtk.canvas2d.elements.camera_image import ( + CameraImageElement, +) + +pytestmark = pytest.mark.ui + +INSIDE = (150, 150) +OUTSIDE = (90, 150) +BEYOND_COVERAGE = (50, 150) + + +def _controller() -> CameraController: + """An aligned 640x480 white frame covering world x in [-25, 135].""" + camera = Camera("Test Camera", "0") + camera.enabled = True + camera.image_to_world = ( + [(100, 100), (500, 100), (500, 400), (100, 400)], + [(0, 100), (100, 100), (100, 0), (0, 0)], + ) + camera.outside_view_margin_mm = 20 + controller = CameraController(camera) + controller._image_data = np.full((480, 640, 3), 255, dtype=np.uint8) + return controller + + +def _element(controller, supported=True) -> CameraImageElement: + canvas = MagicMock() + canvas.width_mm = 100.0 + canvas.height_mm = 100.0 + canvas._cam_visible = True + canvas.get_width.return_value = 200 + canvas.get_height.return_value = 200 + element = CameraImageElement(controller, outside_view_supported=supported) + element.canvas = canvas + element.allocate() + return element + + +def _process(element: CameraImageElement) -> None: + key = element._current_key() + assert key is not None + element._process_and_update_cache(key) + assert element._cached_key == key + + +def _render(element: CameraImageElement) -> np.ndarray: + """Render at 1 px/mm with world (0, 0) at pixel (100, 200).""" + surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, 300, 300) + ctx = cairo.Context(surface) + ctx.translate(100, 200) + ctx.scale(1, -1) + with patch("rayforge.ui_gtk.canvas2d.elements.camera_image.GLib"): + element.render(ctx) + surface.flush() + buf = np.frombuffer(surface.get_data(), dtype=np.uint8) + return buf.reshape(300, 300, 4) + + +def _alpha(pixels: np.ndarray, point: tuple[int, int]) -> int: + x, y = point + return int(pixels[y, x, 3]) + + +def test_outside_view_disabled_matches_existing_render(): + controller = _controller() + legacy = _element(controller, supported=False) + element = _element(controller) + _process(legacy) + with patch.object( + controller, + "get_work_surface_images", + wraps=controller.get_work_surface_images, + ) as paired: + _process(element) + paired.assert_not_called() + + np.testing.assert_array_equal(_render(element), _render(legacy)) + assert _alpha(_render(element), OUTSIDE) == 0 + + +def test_outside_view_draws_only_outside_with_own_transparency(): + controller = _controller() + element = _element(controller) + _process(element) + inside_alpha = _alpha(_render(element), INSIDE) + + controller.config.outside_view_enabled = True + _process(element) + pixels = _render(element) + + assert _alpha(pixels, INSIDE) == inside_alpha + assert _alpha(pixels, OUTSIDE) == pytest.approx(0.65 * 255, abs=1) + assert _alpha(pixels, BEYOND_COVERAGE) == 0 + # The margin ends 20 mm outside the workspace. + assert _alpha(pixels, (75, 150)) == 0 + + controller.config.outside_view_transparency = 0.3 + pixels = _render(element) + assert _alpha(pixels, OUTSIDE) == pytest.approx(0.3 * 255, abs=1) + assert _alpha(pixels, INSIDE) == inside_alpha + + +def test_outside_view_is_hidden_immediately_when_invalidated(): + controller = _controller() + controller.config.outside_view_enabled = True + element = _element(controller) + _process(element) + assert _alpha(_render(element), OUTSIDE) > 0 + + controller.config.outside_view_margin_mm = 10 + assert _alpha(_render(element), OUTSIDE) == 0 + + controller.config.outside_view_margin_mm = 20 + controller.config.outside_view_enabled = False + assert _alpha(_render(element), OUTSIDE) == 0 + + controller.config.outside_view_enabled = True + controller.config.image_to_world = None + assert _alpha(_render(element), OUTSIDE) == 0 + + +def test_stale_job_does_not_commit_after_settings_change(): + controller = _controller() + element = _element(controller) + key = element._current_key() + assert key is not None + + controller.config.outside_view_enabled = True + element._process_and_update_cache(key) + + assert element._cached_surface is None + assert element._outside_cache is None + + +def test_draw_does_not_schedule_duplicate_jobs(): + controller = _controller() + element = _element(controller) + ctx = cairo.Context(cairo.ImageSurface(cairo.FORMAT_ARGB32, 10, 10)) + + with patch("rayforge.ui_gtk.canvas2d.elements.camera_image.GLib") as glib: + element.draw(ctx) + element.draw(ctx) + + assert glib.idle_add.call_count == 1 + + +def test_outside_view_change_invalidates_cached_surface(): + """Outside view settings emit `changed` only, which must still + invalidate the element's caches and queue a redraw.""" + controller = _controller() + controller.config.outside_view_enabled = True + element = _element(controller) + _process(element) + assert element._cached_key is not None + + with patch.object(element, "canvas") as canvas: + controller.config.outside_view_transparency = 0.4 + assert canvas.queue_draw.called + + assert element._cached_key is None + + +def test_element_without_outside_support_keeps_its_clip(): + controller = _controller() + controller.config.outside_view_enabled = True + + element = _element(controller, supported=False) + + assert element.clip + assert element._outside_margin_request() is None diff --git a/website/docs/machine/camera.md b/website/docs/machine/camera.md index 9ef1f6b03..e41870996 100644 --- a/website/docs/machine/camera.md +++ b/website/docs/machine/camera.md @@ -356,6 +356,25 @@ icon in the main window toolbar. --- +### View Outside the Workspace + +The camera usually sees more than just the work area. In Machine settings → Camera → View Outside +Workspace, you can show an additional semi-transparent border of camera image around the workspace, +which makes it easier to position workpieces. + +![Camera view outside the workspace](/screenshots/main-camera-outside-view.webp) + +- **Show Outside Workspace** turns the border on or off. It is off by default and requires an + aligned camera. +- **Margin** controls how far the border extends beyond the work area, up to 100 mm. +- **Transparency** controls how visible the border is. + +Areas the camera cannot see stay transparent instead of showing black, and the border ends at the +edge of what the camera actually covers. Objects sticking up above the bed, such as the gantry, can +appear shifted in the border, because the camera image is aligned to the bed plane. + +--- + ### Multiple Cameras Rayforge supports multiple cameras for different views or machines: diff --git a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md index 6ca39dfc4..9088cb1f7 100644 --- a/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/de/docusaurus-plugin-content-docs/current/machine/camera.md @@ -298,6 +298,26 @@ Klicken auf das Kamerasymbol in der Hauptfenster-Symbolleiste ein oder aus. --- +### Ansicht außerhalb des Arbeitsbereichs + +Die Kamera sieht meist mehr als nur den Arbeitsbereich. Unter Maschineneinstellungen → Kamera → +Ansicht außerhalb des Arbeitsbereichs kannst du einen zusätzlichen halbtransparenten Rand aus +Kamerabild um den Arbeitsbereich anzeigen lassen, der das Positionieren von Werkstücken erleichtert. + +![Kameraansicht außerhalb des Arbeitsbereichs](/screenshots/main-camera-outside-view.webp) + +- **Außerhalb des Arbeitsbereichs anzeigen** schaltet den Rand ein oder aus. Er ist standardmäßig + deaktiviert und erfordert eine ausgerichtete Kamera. +- **Rand** legt fest, wie weit der Rand über den Arbeitsbereich hinausgeht, bis zu 100 mm. +- **Transparenz** steuert, wie gut der Rand sichtbar ist. + +Bereiche, die die Kamera nicht sieht, bleiben transparent, statt schwarz zu erscheinen, und der Rand +endet an der Kante dessen, was die Kamera tatsächlich abdeckt. Objekte, die über das Bett ragen, wie +der Portalrahmen, können im Rand verschoben erscheinen, da das Kamerabild auf die Bettebene +ausgerichtet ist. + +--- + ### Mehrere Kameras Rayforge unterstützt mehrere Kameras für verschiedene Ansichten oder Maschinen: diff --git a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md index 35a3ce9ee..15d626375 100644 --- a/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/es/docusaurus-plugin-content-docs/current/machine/camera.md @@ -303,6 +303,26 @@ haciendo clic en el icono de cámara en la barra de herramientas de la ventana p --- +### Vista fuera del área de trabajo + +La cámara normalmente ve más que solo el área de trabajo. En Configuración de máquina → Cámara → +Vista fuera del área de trabajo, puedes mostrar un borde adicional semitransparente con la imagen de +la cámara alrededor del área de trabajo, lo que facilita posicionar las piezas. + +![Vista de la cámara fuera del área de trabajo](/screenshots/main-camera-outside-view.webp) + +- **Mostrar fuera del área de trabajo** activa o desactiva el borde. Está desactivado por defecto y + requiere una cámara alineada. +- **Margen** controla hasta dónde se extiende el borde más allá del área de trabajo, hasta 100 mm. +- **Transparencia** controla la visibilidad del borde. + +Las zonas que la cámara no puede ver permanecen transparentes en lugar de mostrarse en negro, y el +borde termina donde la cámara deja de ver. Los objetos que sobresalen por encima de la cama, como el +pórtico, pueden aparecer desplazados en el borde, porque la imagen de la cámara está alineada con el +plano de la cama. + +--- + ### Múltiples Cámaras Rayforge soporta múltiples cámaras para diferentes vistas o máquinas: diff --git a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md index d5f09c25c..37ec2b57b 100644 --- a/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/fr/docusaurus-plugin-content-docs/current/machine/camera.md @@ -305,6 +305,27 @@ en cliquant sur l'icône caméra dans la barre d'outils de la fenêtre principal --- +### Vue hors de la zone de travail + +La caméra voit généralement plus que la seule zone de travail. Dans Paramètres de la machine → +Caméra → Vue hors de la zone de travail, tu peux afficher une bordure supplémentaire +semi-transparente de l'image caméra autour de la zone de travail, ce qui facilite le positionnement +des pièces. + +![Vue caméra hors de la zone de travail](/screenshots/main-camera-outside-view.webp) + +- **Afficher hors de la zone de travail** active ou désactive la bordure. Elle est désactivée par + défaut et nécessite une caméra alignée. +- **Marge** contrôle la distance dont la bordure dépasse de la zone de travail, jusqu'à 100 mm. +- **Transparence** contrôle la visibilité de la bordure. + +Les zones que la caméra ne voit pas restent transparentes au lieu d'apparaître en noir, et la +bordure s'arrête à la limite de ce que la caméra couvre réellement. Les objets dépassant au-dessus +du lit, comme le portique, peuvent sembler décalés dans la bordure, car l'image caméra est alignée +sur le plan du lit. + +--- + ### Caméras Multiples Rayforge supporte plusieurs caméras pour différentes vues ou machines : diff --git a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md index e5074b75a..cfc640682 100644 --- a/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/hi/docusaurus-plugin-content-docs/current/machine/camera.md @@ -300,6 +300,25 @@ YUYV विकल्प तब उपयोगी है जब आपका क --- +### वर्कस्पेस के बाहर का दृश्य + +कैमरा आमतौर पर केवल वर्कस्पेस से अधिक देखता है. मशीन सेटिंग्स → कैमरा → वर्कस्पेस के बाहर का दृश्य +में, आप वर्कस्पेस के चारों ओर कैमरा इमेज का एक अतिरिक्त अर्ध-पारदर्शी किनारा दिखा सकते हैं, जिससे +वर्कपीस को स्थित करना आसान हो जाता है. + +![वर्कस्पेस के बाहर कैमरा दृश्य](/screenshots/main-camera-outside-view.webp) + +- **वर्कस्पेस के बाहर दिखाएं** किनारे को चालू या बंद करता है. यह डिफ़ॉल्ट रूप से बंद रहता है और + संरेखित कैमरे की आवश्यकता होती है. +- **मार्जिन** यह नियंत्रित करता है कि किनारा वर्कस्पेस से कितनी दूर तक फैले, 100 मिमी तक. +- **पारदर्शिता** किनारे की दृश्यता को नियंत्रित करती है. + +जहां कैमरा नहीं देख सकता, वहां काला दिखाने के बजाय पारदर्शी रहता है, और किनारा उसी सीमे पर समाप्त +होता है जहां तक कैमरा वास्तव में देखता है. बेड से ऊपर उठी वस्तुएं, जैसे गैंट्री, किनारे में खिसकी +हुई दिख सकती हैं, क्योंकि कैमरा इमेज बेड के तल से संरेखित होती है. + +--- + ### कई कैमरे Rayforge भिन्न दृश्यों या मशीनों के लिए कई कैमरे समर्थन करता है: diff --git a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md index 8dbad56c4..eebfb915b 100644 --- a/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/pt-BR/docusaurus-plugin-content-docs/current/machine/camera.md @@ -296,6 +296,25 @@ clicando no ícone da câmera na barra de ferramentas da janela principal. --- +### Visualização fora da área de trabalho + +A câmera geralmente vê mais do que apenas a área de trabalho. Em Configurações da máquina → Câmera → +Visualização fora da área de trabalho, você pode exibir uma borda adicional semitransparente com a +imagem da câmera ao redor da área de trabalho, o que facilita posicionar as peças. + +![Visualização da câmera fora da área de trabalho](/screenshots/main-camera-outside-view.webp) + +- **Mostrar fora da área de trabalho** liga ou desliga a borda. Ela fica desligada por padrão e + exige uma câmera alinhada. +- **Margem** controla até onde a borda se estende além da área de trabalho, até 100 mm. +- **Transparência** controla a visibilidade da borda. + +As áreas que a câmera não consegue ver permanecem transparentes em vez de aparecer em preto, e a +borda termina no limite do que a câmera realmente cobre. Objetos que se elevam acima da mesa, como o +pórtico, podem aparecer deslocados na borda, porque a imagem da câmera é alinhada ao plano da mesa. + +--- + ### Múltiplas Câmeras O Rayforge suporta múltiplas câmeras para diferentes visões ou máquinas: diff --git a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md index 2ab33cdbf..5561afeec 100644 --- a/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/uk/docusaurus-plugin-content-docs/current/machine/camera.md @@ -289,6 +289,25 @@ Offset** на 0. --- +### Вигляд поза робочою областю + +Камера зазвичай бачить більше, ніж лише робочу область. У Налаштуваннях машини → Камера → Вигляд +поза робочою областю можна показати додаткову напівпрозору рамку з зображення камери навколо робочої +області, що полегшує позиціонування заготовок. + +![Вигляд камери поза робочою областю](/screenshots/main-camera-outside-view.webp) + +- **Показувати поза робочою областю** вмикає або вимикає рамку. Типово вона вимкнена та потребує + вирівняної камери. +- **Поле** визначає, наскільки рамка виходить за межі робочої області, до 100 мм. +- **Прозорість** визначає помітність рамки. + +Ділянки, які камера не бачить, залишаються прозорими замість чорних, а рамка закінчується на межі +того, що камера фактично охоплює. Об'єкти, що виступають над столом, як-от портал, можуть виглядати +зміщеними в рамці, оскільки зображення камери вирівняне до площини столу. + +--- + ### Кілька камер Rayforge підтримує кілька камер для різних видів або машин: diff --git a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md index 5844079f8..78da1ed66 100644 --- a/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md +++ b/website/i18n/zh-CN/docusaurus-plugin-content-docs/current/machine/camera.md @@ -246,6 +246,20 @@ Rayforge 默认推荐交错纸,因此如果您的纸是普通矩形排列, --- +### 工作区域之外的视图 + +相机通常能看到工作区域以外的内容。在 机器设置 → 相机 → 工作区域之外的视图 中,可以在工作区域周围显示一圈额外的半透明相机图像边框,便于放置工件。 + +![工作区域之外的相机视图](/screenshots/main-camera-outside-view.webp) + +- **显示工作区域之外** 用于开关该边框。默认关闭,且需要已完成对齐的相机。 +- **边距** 控制边框超出工作区域的距离,最大 100 毫米。 +- **透明度** 控制边框的可见程度。 + +相机看不到的区域保持透明而不是显示为黑色,边框也会在相机实际覆盖范围的边缘处截止。高于床面的物体(如横梁)在边框中可能出现偏移,因为相机图像是对齐到床面平面的。 + +--- + ### 多相机 Rayforge 支持多个相机用于不同视图或机器: diff --git a/website/static/screenshots/machine-settings-camera.webp b/website/static/screenshots/machine-settings-camera.webp index a4a0be6e9..18a479bd1 100644 Binary files a/website/static/screenshots/machine-settings-camera.webp and b/website/static/screenshots/machine-settings-camera.webp differ diff --git a/website/static/screenshots/main-camera-outside-view.webp b/website/static/screenshots/main-camera-outside-view.webp new file mode 100644 index 000000000..62ed76f90 Binary files /dev/null and b/website/static/screenshots/main-camera-outside-view.webp differ