diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 00000000..ae5a3777 --- /dev/null +++ b/.github/copilot-instructions.md @@ -0,0 +1,97 @@ +# Pympress — Copilot Instructions + +Pympress is a dual-screen PDF presentation tool (like PowerPoint Presenter View) built with **GTK 3** (PyGObject) and **Poppler**. Licensed GPLv2+. + +## Quick Reference + +| Action | Command | +|---|---| +| Install (dev) | `pip install -e .` | +| Install deps only | `pip install -r requirements.txt` | +| Lint | `flake8 . --count --show-source --statistics --select=E,F,W,C` | +| Lint docstrings | `flake8 . --select=D` | +| Build docs | `sphinx-build -b html docs build/sphinx/html` | +| Extract i18n | `pybabel extract -F pympress/share/locale/babel_mapping.cfg -o pympress/share/locale/pympress.pot .` | +| Compile translations | `pybabel compile -d pympress/share/locale/ -D pympress` | +| Run | `python -m pympress [file.pdf]` | + +There is **no automated test suite** — linting (flake8) is the primary CI check. + +## Architecture + +Component-based, event-driven design around a central `UI` object. Not strict MVC, but clear separation: + +- **Model**: `Document` / `Page` in [pympress/document.py](../pympress/document.py) — pure PDF data, no GTK imports except rendering +- **View**: Glade XML files in `pympress/share/xml/` define widget trees, loaded by `Builder` +- **Controller**: `UI` (in [pympress/ui.py](../pympress/ui.py)) owns all sub-components and handles events + +### Key modules + +| Module | Role | +|---|---| +| `ui.py` | Main `UI(Builder)` class — creates Content + Presenter windows, wires everything | +| `document.py` | `Document`, `Page`, `PdfPage` — GUI-independent PDF model via Poppler | +| `builder.py` | `Builder(Gtk.Builder)` — loads Glade XML, auto-translates strings, introspectively binds widgets to object attributes by matching IDs to `None`-valued class attributes | +| `config.py` | `Config(ConfigParser)` — `defaults.conf` → user config → CLI overrides. Layouts stored as JSON within INI | +| `extras.py` | Auxiliary features: `Media`, `TimingReport`, `Annotations`, `Zoom`, `Cursor`, `FileWatcher` | +| `scribble.py` | Freehand drawing/annotation overlay on slides | +| `pointer.py` | Software laser pointer | +| `talk_time.py` | Elapsed/remaining time with color-coded warnings | +| `editable_label.py` | Click-to-edit labels (page number, estimated talk time) | +| `surfacecache.py` | Thread-safe LRU cache of rendered `cairo.ImageSurface` pages | +| `util.py` | Platform detection (`IS_WINDOWS`/`IS_MAC_OS`/`IS_POSIX`), resource paths, icon/CSS loading | +| `__main__.py` | Entry point — CLI parsing (`getopt`), locale/gettext setup, `Gtk.main()` | + +### Media overlays (plugin system) + +`pympress/media_overlays/` provides a backend abstraction for video/animation playback: + +- **Base**: `VideoOverlay(Builder)` in `base.py` — abstract interface (`do_play`, `do_stop`, etc.), overlay positioning, progress bar +- **GIF**: `gif_backend.py` — `GdkPixbuf.PixbufAnimation` frame-by-frame rendering +- **GStreamer**: `gst_backend.py` — `GstPlayer.Player` for full video +- **VLC**: `vlc_backend.py` — `vlc.MediaPlayer` for full video +- Backends are lazily registered by mime type in `Media._setup_backends()` (`extras.py`). Priority: GIF → GStreamer → VLC (last becomes default catch-all) +- Each media element creates **two** overlay instances (content window muted + presenter window with audio) + +### Wiring pattern + +`UI.__init__()` creates sub-components → calls `Builder.load_ui(name)` for each Glade file → `connect_signals(self)` resolves signal handlers via dot-path notation (e.g. `doc.goto_page` resolves `self.doc.goto_page`). + +## Code Conventions + +- **Style**: PEP 8, 120-char line limit, 4-space indent — see `[style]` and `[flake8]` in [setup.cfg](../setup.cfg) +- **Docstrings**: Google style with types in backticks (e.g. `` `str` ``), no type annotations in signatures +- **Naming**: `snake_case` functions/variables, `PascalCase` classes, `_`/`__` prefix for private +- **Imports**: standard lib → `gi.repository` → `pympress` modules. `from __future__ import print_function, unicode_literals` in every file (Python 2/3 compat) +- **gettext**: `_()` builtin installed by `gettext.install()`. All user-facing strings wrapped in `_()` +- **Widget binding**: Declare `attribute = None` on the class, give the Glade widget the same `id` — `Builder` auto-assigns it +- **Flake8 config**: Some rules are deliberately ignored (alignment flexibility) — see `[flake8]` in setup.cfg before "fixing" style warnings + +## Internationalization + +- Translations managed via [POEditor](https://poeditor.com/join/project/nKfRxeN8pS) — do not edit `.po` files by hand for existing languages +- Extraction: `babel` + `babelgladeextractor` from both Python and Glade sources +- `.mo` compiled at install time. Supported locales: cs, de, es, fr, pl +- `Builder.__translate_widget_strings()` applies `_()` to all string properties of loaded widgets + +## Glade UI files (`pympress/share/xml/`) + +| File | Purpose | +|---|---| +| `presenter.glade` | Presenter window (current/next slide, notes, annotations, timer, menus) | +| `content.glade` | Audience window (fullscreen slide) | +| `media_overlay.glade` | Video overlay widget with progress bar and controls | +| `shortcuts.glade` | Keyboard shortcuts help window | +| `time_report_dialog.glade` | Per-section timing breakdown dialog | + +## Dependencies (non-pip, system-level) + +PyGObject, PyCairo, GTK 3, Cairo, Poppler (with GObject introspection bindings). Optionally VLC. See [README.md](../README.md#dependencies) for platform-specific install commands. + +## Common Pitfalls + +- GTK operations must happen on the main thread; `GLib.idle_add()` for cross-thread UI updates +- `Builder` widget auto-binding only works if the class attribute is set to `None` before `load_ui()` +- Config values may come as strings from INI — use `getboolean()`, `getint()`, etc. +- Platform-specific code paths (especially Windows) — check `util.IS_WINDOWS` and test accordingly +- No type hints in the codebase — don't add them unless specifically asked diff --git a/.github/workflows/deploy_doc+l10n.yml b/.github/workflows/deploy_doc+l10n.yml index 8c9ca62e..c1f659b0 100644 --- a/.github/workflows/deploy_doc+l10n.yml +++ b/.github/workflows/deploy_doc+l10n.yml @@ -21,7 +21,7 @@ jobs: sudo apt-get install -qy jq pip install -e .[babel] - name: Extract - run: python setup.py extract_messages + run: pybabel extract -F pympress/share/locale/babel_mapping.cfg -o pympress/share/locale/pympress.pot --no-location --no-wrap --sort-output --omit-header . - name: Upload env: poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} @@ -41,13 +41,13 @@ jobs: run: | sudo apt-get update -q sudo apt-get install -qy jq gobject-introspection libgirepository-1.0-1 gir1.2-gtk-3.0 gir1.2-glib-2.0 gir1.2-gstreamer-1.0 gir1.2-poppler-0.18 python3-pip python3-setuptools python3-wheel libgirepository1.0-dev - pip install pygobject pycairo .[build_sphinx] + pip install pygobject pycairo .[docs] - name: Build env: poeditor_api_token: ${{ secrets.POEDITOR_API_TOKEN }} run: | ./scripts/poedit.sh contributors - python setup.py build_sphinx + sphinx-build -b html docs build/sphinx/html tar czf pympress-docs.tar.gz -C build/sphinx/html/ . - name: Upload uses: actions/upload-artifact@v1 diff --git a/.gitignore b/.gitignore index 64427bd6..b12b2eae 100644 --- a/.gitignore +++ b/.gitignore @@ -13,3 +13,6 @@ tpym pympress/share/pixmaps/icons core.* empty.* + +.venv/ +.vscode/ diff --git a/pympress/config.py b/pympress/config.py index 81bed3ec..6d1e64cb 100644 --- a/pympress/config.py +++ b/pympress/config.py @@ -152,12 +152,12 @@ def __init__(config): super(Config, config).__init__() # populate values first from the default config file, then from the proper one - config.read(util.get_default_config()) + config.read(util.get_default_config(), encoding='utf-8') config.load_window_layouts() all_commands = dict(config.items('shortcuts')).keys() - config.read(config.path_to_config(True)) + config.read(config.path_to_config(True), encoding='utf-8') config.upgrade() config.load_window_layouts() @@ -197,7 +197,7 @@ def save_config(self): for layout_name in self.layout: self.set('layout', layout_name, json.dumps(self.layout[layout_name], indent=4)) - with open(self.path_to_config(), 'w') as configfile: + with open(self.path_to_config(), 'w', encoding='utf-8') as configfile: self.write(configfile) diff --git a/pympress/document.py b/pympress/document.py index a7061bfc..df20050f 100644 --- a/pympress/document.py +++ b/pympress/document.py @@ -696,7 +696,7 @@ def __init__(self, builder, pop_doc, path, page=0, old_doc=None): self.scribbles = {} self.highlight_mode = builder.highlight_mode try: - f = open(self.path + '.pymp', "r") + f = open(self.path + '.pymp', "r", encoding='utf-8') in_dict = json.load(f) self.page_map = {int(key): val for key, val in in_dict.get('page_map', {}).items() if val < self.doc.get_n_pages()} mapped_pages = self.page_map.values() @@ -789,7 +789,7 @@ def default(self, obj): path = self.path if path[:7] == 'file://': path = path[7:] - f = open(path + '.pymp', "w") + f = open(path + '.pymp', "w", encoding='utf-8') json.dump(out_dict, f, cls=RGBAEncoder) def export_pdf(self, filename=None): @@ -807,7 +807,7 @@ def export_xopp(self, filename=None, f=None): if filename is None: filename = self.path + '.xopp' if f is None: - f = open(filename, "w") + f = open(filename, "w", encoding='utf-8') print(""" Xournal++ document - see https://github.com/xournalpp/xournalpp diff --git a/pympress/scribble.py b/pympress/scribble.py index 3175ce98..df7a8391 100644 --- a/pympress/scribble.py +++ b/pympress/scribble.py @@ -34,6 +34,8 @@ import io import copy +from pympress import shape_recognition + import gi import cairo gi.require_version('Gtk', '3.0') @@ -823,6 +825,10 @@ def toggle_scribble(self, widget, e_type, point, button, always=False, state=0): if self.drawing_mode in ["box", "ellipse"]: self.scribble_list[-1][4] = [x[:] for x in self.scribble_list[-1][3]] + # Shape recognition: convert freehand to geometric shape when Shift is held + if self.drawing_mode == "draw" and state & Gdk.ModifierType.SHIFT_MASK: + self._try_recognize_shape(state, widget) + self.scribble_drawing = False if self.have_pen and self.pen_pointer is not None: self.pen_pointer[0] = [] @@ -831,6 +837,100 @@ def toggle_scribble(self, widget, e_type, point, button, always=False, state=0): return False + def _try_recognize_shape(self, state, widget): + """ Attempt to convert the last freehand stroke to a geometric shape. + + Called on BUTTON_RELEASE when Shift is held in draw mode. + Converts the scribble in-place and adds an undo entry so the user + can revert to the original freehand stroke. + + Args: + state (`int`): modifier key state from the GTK event + widget (:class:`~Gtk.Widget`): the widget for aspect ratio computation + """ + if not self.scribble_list: + logger.debug('shape_recognition: no scribbles') + return + + scribble = self.scribble_list[-1] + if scribble[0] != "segment" or len(scribble[3]) < shape_recognition.MIN_POINTS_LINE: + logger.debug('shape_recognition: skip — type=%s, npoints=%d', + scribble[0], len(scribble[3]) if scribble[3] else 0) + return + + logger.debug('shape_recognition: analyzing %d points', len(scribble[3])) + result = shape_recognition.recognize_shape(scribble[3]) + if result is None: + logger.debug('shape_recognition: no shape detected — dumping points for diagnosis') + if logger.isEnabledFor(logging.DEBUG): + for i, p in enumerate(scribble[3]): + logger.debug(' pt[%d] = (%.6f, %.6f)', i, p[0], p[1]) + return + + logger.debug('shape_recognition: detected %s with params %s', result[0], result[1]) + + shape_type, params = result + old_state = copy.deepcopy(scribble[:]) + + make_arrow = bool(state & Gdk.ModifierType.CONTROL_MASK) + + if shape_type == 'line': + start, end = params + scribble[0] = "segment" + scribble[3] = [start, end] + scribble[4] = [list(map(min, zip(start, end))), list(map(max, zip(start, end)))] + # Truncate any extra elements from the freehand stroke + del scribble[5:] + + if make_arrow: + aspect = widget.get_allocated_width() / widget.get_allocated_height() if widget else 1 + line_vec = (start[0] - end[0], (start[1] - end[1]) / aspect) + angle = math.atan2(line_vec[1], line_vec[0]) + scribble[3].append([ + end[0] + 0.04 * math.cos(angle + math.pi / 6), + end[1] + 0.04 * math.sin(angle + math.pi / 6) * aspect]) + scribble[3].append([ + end[0] + 0.04 * math.cos(angle - math.pi / 6), + end[1] + 0.04 * math.sin(angle - math.pi / 6) * aspect]) + scribble[3].append([end[0], end[1]]) + scribble.append([]) + + elif shape_type == 'circle': + center, radius = params + corner1 = [center[0] - radius, center[1] - radius] + corner2 = [center[0] + radius, center[1] + radius] + scribble[0] = "ellipse" + scribble[3] = [corner1, corner2] + scribble[4] = [corner1[:], corner2[:]] + del scribble[5:] + scribble.append((0, 0, 0, 0)) + + elif shape_type == 'ellipse': + center, half_w, half_h = params + corner1 = [center[0] - half_w, center[1] - half_h] + corner2 = [center[0] + half_w, center[1] + half_h] + scribble[0] = "ellipse" + scribble[3] = [corner1, corner2] + scribble[4] = [corner1[:], corner2[:]] + del scribble[5:] + scribble.append((0, 0, 0, 0)) + + elif shape_type == 'rectangle': + corner1, corner2 = params + scribble[0] = "box" + scribble[3] = [corner1, corner2] + scribble[4] = [corner1[:], corner2[:]] + del scribble[5:] + scribble.append((0, 0, 0, 0)) + + else: + return + + new_state = copy.deepcopy(scribble[:]) + self.add_undo(('r', scribble, old_state, new_state)) + self.redraw_current_slide() + + def draw_scribble(self, widget, cairo_context, draw_selected, pw): """ Perform the drawings by user. @@ -1364,6 +1464,8 @@ def undo(self, *args): s[5] = oc elif op[0] == 'm': adjust_scribbles(op[1], -op[2], -op[3]) + elif op[0] == 'r': + op[1][:] = op[2] self.redraw_current_slide() return True @@ -1393,6 +1495,8 @@ def redo(self, *args): s[5] = nc elif op[0] == 'm': adjust_scribbles(op[1], op[2], op[3]) + elif op[0] == 'r': + op[1][:] = op[3] self.undo_stack_pos = self.undo_stack_pos + 1 if self.undo_stack_pos == len(self.undo_stack): self.buttons["redo"].set_sensitive(False) diff --git a/pympress/shape_recognition.py b/pympress/shape_recognition.py new file mode 100644 index 00000000..9055c83d --- /dev/null +++ b/pympress/shape_recognition.py @@ -0,0 +1,382 @@ +# -*- coding: utf-8 -*- +# +# shape_recognition.py +# +# Copyright 2026 Cimbali +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation; either version 2 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program; if not, write to the Free Software +# Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, +# MA 02110-1301, USA. +""" +:mod:`pympress.shape_recognition` -- Detect geometric shapes from freehand strokes +----------------------------------------------------------------------------------- + +Pure math module using numpy. No GTK imports. + +When the user draws freehand with Shift held, the stroke points are analyzed +and converted to a geometric shape if the fit is good enough. + +Supported shapes: line, circle, ellipse, rectangle. +""" + +from __future__ import print_function, unicode_literals + +import logging +import math +import numpy as np + +logger = logging.getLogger(__name__) + + +# --- Thresholds --- +LINE_MAX_DEVIATION_RATIO = 0.05 +ELLIPSE_RADIAL_STD_RATIO = 0.25 +CIRCLE_AXIS_RATIO = 0.90 +CLOSURE_DISTANCE_RATIO = 0.20 +RECT_ANGLE_TOLERANCE = 25 # degrees +RDP_EPSILON_RATIO = 0.03 +MIN_POINTS_LINE = 3 +MIN_POINTS_SHAPE = 8 + + +def _is_closed(pts): + """ Check whether a stroke's start and end are close together. + + Args: + pts (`numpy.ndarray`): Nx2 array of points + + Returns: + `bool`: True if the stroke is approximately closed + """ + d = np.linalg.norm(pts[0] - pts[-1]) + bbox_diag = np.linalg.norm(pts.max(axis=0) - pts.min(axis=0)) + if bbox_diag < 1e-9: + return False + ratio = d / bbox_diag + closed = ratio < CLOSURE_DISTANCE_RATIO + if not closed: + logger.debug('_is_closed: ratio=%.4f > threshold %.2f — stroke is open', ratio, CLOSURE_DISTANCE_RATIO) + return closed + + +def fit_line(pts): + """ Fit a line to 2D points using SVD. + + Args: + pts (`numpy.ndarray`): Nx2 array of points + + Returns: + `tuple` or None: (start, end) as [x,y] lists, or None if the fit is poor + """ + if len(pts) < MIN_POINTS_LINE: + return None + + centroid = pts.mean(axis=0) + centered = pts - centroid + _, s, vt = np.linalg.svd(centered, full_matrices=False) + + # Direction of best-fit line + direction = vt[0] + + # Project all points onto the line + projections = centered @ direction + residuals = centered - np.outer(projections, direction) + max_deviation = float(np.max(np.sqrt(np.sum(residuals ** 2, axis=1)))) + + # Use extent along principal axis (not stroke arc length, which inflates for zigzag paths) + extent = float(projections.max() - projections.min()) + if extent < 1e-9: + return None + + dev_ratio = max_deviation / extent + if dev_ratio > LINE_MAX_DEVIATION_RATIO: + logger.debug('fit_line: deviation_ratio=%.4f > threshold %.2f — rejected', dev_ratio, LINE_MAX_DEVIATION_RATIO) + return None + + # Project first and last points onto line to get clean endpoints + t_first = float(np.dot(pts[0] - centroid, direction)) + t_last = float(np.dot(pts[-1] - centroid, direction)) + + start = centroid + t_first * direction + end = centroid + t_last * direction + + return [start.tolist(), end.tolist()] + + +def fit_ellipse(pts): + """ Fit an ellipse (or circle) to 2D points using PCA-based approach. + + Args: + pts (`numpy.ndarray`): Nx2 array of points (should form a closed stroke) + + Returns: + `tuple` or None: (center, semi_a, semi_b, is_circle) where center is [x,y], + semi_a/semi_b are axis-aligned half-widths, and is_circle is True if axes + are within 90% of each other. Returns None if the fit is poor. + """ + if len(pts) < MIN_POINTS_SHAPE: + return None + + if not _is_closed(pts): + return None + + centroid = pts.mean(axis=0) + centered = pts - centroid + + # PCA: eigendecompose the covariance matrix + cov = np.cov(centered.T) + eigenvalues, eigenvectors = np.linalg.eigh(cov) + + # Sort by descending eigenvalue + idx = np.argsort(eigenvalues)[::-1] + eigenvalues = eigenvalues[idx] + eigenvectors = eigenvectors[:, idx] + + # Transform points to principal-axis frame + transformed = centered @ eigenvectors + + # In the principal frame, estimate semi-axes from the extent of the points. + # For a uniform ellipse traced by hand, the semi-axis is ~ max extent along each axis. + # Use a robust estimator: percentile-based half-range (98th to avoid underestimation from noise) + semi_a = float(np.percentile(np.abs(transformed[:, 0]), 98)) + semi_b = float(np.percentile(np.abs(transformed[:, 1]), 98)) + + if semi_a < 1e-9 or semi_b < 1e-9: + logger.debug('fit_ellipse: degenerate axes (semi_a=%.6f, semi_b=%.6f)', semi_a, semi_b) + return None + + # Quality check: how well do points lie on the ellipse? + # Normalized radial distance: (x/a)^2 + (y/b)^2 should be ~1 + radial = (transformed[:, 0] / semi_a) ** 2 + (transformed[:, 1] / semi_b) ** 2 + radial_std = float(np.std(radial - 1.0)) + + if radial_std > ELLIPSE_RADIAL_STD_RATIO: + logger.debug('fit_ellipse: radial_std=%.4f > threshold %.2f — rejected', radial_std, ELLIPSE_RADIAL_STD_RATIO) + return None + + # Check if close enough to a circle + axis_ratio = min(semi_a, semi_b) / max(semi_a, semi_b) + is_circle = axis_ratio >= CIRCLE_AXIS_RATIO + + if is_circle: + radius = (semi_a + semi_b) / 2.0 + semi_a = semi_b = radius + + # Convert back to original coordinate frame + # The axis-aligned bounding box of the ellipse in the original frame + # For axis-aligned ellipses (which pympress supports), we need the AABB + # of the rotated ellipse. + # Parametric: x(t) = a*cos(t)*e1x + b*sin(t)*e2x + cx + # y(t) = a*cos(t)*e1y + b*sin(t)*e2y + cy + # Max x: sqrt((a*e1x)^2 + (b*e2x)^2), similarly for y + e1 = eigenvectors[:, 0] + e2 = eigenvectors[:, 1] + half_w = math.sqrt((semi_a * e1[0]) ** 2 + (semi_b * e2[0]) ** 2) + half_h = math.sqrt((semi_a * e1[1]) ** 2 + (semi_b * e2[1]) ** 2) + + center = centroid.tolist() + return (center, half_w, half_h, is_circle) + + +def _rdp_simplify(pts, epsilon): + """ Ramer-Douglas-Peucker polyline simplification. + + Args: + pts (`numpy.ndarray`): Nx2 array of points + epsilon (`float`): maximum distance threshold + + Returns: + `numpy.ndarray`: simplified array of points + """ + if len(pts) <= 2: + return pts + + # Find the point farthest from the line between first and last + start, end = pts[0], pts[-1] + line_vec = end - start + line_len = np.linalg.norm(line_vec) + + if line_len < 1e-12: + dists = np.sqrt(np.sum((pts - start) ** 2, axis=1)) + else: + line_unit = line_vec / line_len + proj = np.dot(pts - start, line_unit) + proj = np.clip(proj, 0, line_len) + nearest = start + np.outer(proj, line_unit) + dists = np.sqrt(np.sum((pts - nearest) ** 2, axis=1)) + + max_idx = int(np.argmax(dists)) + max_dist = dists[max_idx] + + if max_dist > epsilon: + left = _rdp_simplify(pts[:max_idx + 1], epsilon) + right = _rdp_simplify(pts[max_idx:], epsilon) + return np.vstack([left[:-1], right]) + else: + return np.array([start, end]) + + +def _angle_between(v1, v2): + """ Angle between two 2D vectors in degrees. + + Args: + v1 (`numpy.ndarray`): first vector + v2 (`numpy.ndarray`): second vector + + Returns: + `float`: angle in degrees [0, 180] + """ + cos_a = np.dot(v1, v2) / (np.linalg.norm(v1) * np.linalg.norm(v2) + 1e-12) + cos_a = max(-1.0, min(1.0, cos_a)) + return math.degrees(math.acos(cos_a)) + + +def _remove_collinear(vertices, epsilon): + """ Remove vertices that are collinear with their neighbours. + + A vertex is considered collinear (i.e. it lies on a straight side rather + than at a real corner) when its perpendicular distance to the line through + its two neighbours is less than *epsilon*. + + Args: + vertices (`numpy.ndarray`): Mx2 array of polygon vertices + epsilon (`float`): distance threshold + + Returns: + `numpy.ndarray`: filtered array with collinear vertices removed + """ + keep = [] + n = len(vertices) + for i in range(n): + prev_pt = vertices[(i - 1) % n] + curr_pt = vertices[i] + next_pt = vertices[(i + 1) % n] + edge = next_pt - prev_pt + edge_len = np.linalg.norm(edge) + if edge_len < 1e-12: + keep.append(i) + continue + # Perpendicular distance from curr_pt to the line prev→next + dist = abs(np.cross(edge, curr_pt - prev_pt)) / edge_len + if dist > epsilon: + keep.append(i) + return vertices[keep] if keep else vertices + + +def fit_rectangle(pts): + """ Detect a rectangle from a closed stroke using RDP simplification. + + Args: + pts (`numpy.ndarray`): Nx2 array of points (should form a closed stroke) + + Returns: + `tuple` or None: (corner1, corner2) as [x,y] lists for the axis-aligned + bounding box, or None if the shape is not a rectangle. + """ + if len(pts) < MIN_POINTS_SHAPE: + return None + + if not _is_closed(pts): + return None + + bbox_diag = np.linalg.norm(pts.max(axis=0) - pts.min(axis=0)) + epsilon = bbox_diag * RDP_EPSILON_RATIO + + # Explicitly close the polygon before simplification so RDP preserves + # identical first/last endpoints (RDP always keeps its first and last points). + pts_closed = np.vstack([pts, pts[0:1]]) + simplified = _rdp_simplify(pts_closed, epsilon) + + # We expect ~5 points (4 corners + closing duplicate). + n = len(simplified) + if n < 4 or n > 7: + logger.debug('fit_rectangle: rdp gave %d points (need 4-7) — rejected', n) + return None + + # Drop the closing duplicate (first and last should now be identical) + if np.linalg.norm(simplified[0] - simplified[-1]) <= epsilon: + simplified = simplified[:-1] + + # If the user started mid-side, RDP may keep that point as a vertex + # because it is an endpoint. Remove any vertex that is collinear with + # its neighbours (i.e. lies on a side, not at a corner). + vertices = _remove_collinear(simplified, epsilon) + n_verts = len(vertices) + + if n_verts != 4: + logger.debug('fit_rectangle: %d vertices after collinear removal (need 4) — rejected', n_verts) + return None + + # Check all angles are close to 90° + for i in range(n_verts): + v1 = vertices[(i + 1) % n_verts] - vertices[i] + v2 = vertices[(i - 1) % n_verts] - vertices[i] + angle = _angle_between(v1, v2) + if abs(angle - 90.0) > RECT_ANGLE_TOLERANCE: + logger.debug('fit_rectangle: angle[%d]=%.1f — too far from 90° — rejected', i, angle) + return None + + # Return axis-aligned bounding box of the 4 vertices + mins = vertices.min(axis=0).tolist() + maxs = vertices.max(axis=0).tolist() + return (mins, maxs) + + +def recognize_shape(points): + """ Analyze a freehand stroke and detect if it matches a geometric shape. + + Args: + points (`list`): list of [x, y] coordinate pairs (normalized 0-1) + + Returns: + `tuple` or None: (shape_type, shape_params) where shape_type is one of + 'line', 'circle', 'ellipse', 'rectangle', or None if no shape detected. + + Shape params: + - 'line': ([x0, y0], [x1, y1]) + - 'circle': ([cx, cy], radius) + - 'ellipse': ([cx, cy], half_width, half_height) + - 'rectangle': ([x_min, y_min], [x_max, y_max]) + """ + if not points or len(points) < MIN_POINTS_LINE: + return None + + pts = np.array(points, dtype=float) + + closed = _is_closed(pts) + bbox = pts.max(axis=0) - pts.min(axis=0) + logger.debug('recognize: n=%d, closed=%s, bbox=(%.4f, %.4f)', len(pts), closed, bbox[0], bbox[1]) + + if closed: + # Try rectangle first (most specific closed shape) + rect = fit_rectangle(pts) + if rect is not None: + return ('rectangle', rect) + + # Try ellipse/circle + ell = fit_ellipse(pts) + if ell is not None: + center, half_w, half_h, is_circle = ell + if is_circle: + return ('circle', (center, (half_w + half_h) / 2.0)) + else: + return ('ellipse', (center, half_w, half_h)) + + # Try line (works for open strokes) + line = fit_line(pts) + if line is not None: + return ('line', tuple(line)) + + logger.debug('recognize: no shape matched') + return None diff --git a/pympress/ui.py b/pympress/ui.py index 7c2813a4..58c3fccb 100644 --- a/pympress/ui.py +++ b/pympress/ui.py @@ -267,14 +267,14 @@ def __init__(self, highlight_mode, config_override): self.one_pointer = self.config.getboolean('content', 'one_pointer', fallback=True) - self.scribbler.latex_dict = json.load(open(util.get_latex_dict())) + self.scribbler.latex_dict = json.load(open(util.get_latex_dict(), encoding='utf-8')) self.scribbler.latex_macros = { "latex": { "ctrl": {}, "alt": {}, "altctrl": {}, }, "markup": { "ctrl": {}, "alt": {}, "altctrl": {}, }, "text": { "ctrl": {}, "alt": {}, "altctrl": {}, }, } try: - personal_dict = json.load(open(util.get_personal_dict())) + personal_dict = json.load(open(util.get_personal_dict(), encoding='utf-8')) self.scribbler.latex_dict.update(personal_dict['shortcuts']) for dicts in self.scribbler.latex_macros.keys(): for m in personal_dict[dicts].keys(): diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 00000000..dea87fbf --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,84 @@ +[build-system] +requires = ["setuptools>=64", "babel", "babelgladeextractor"] +build-backend = "setuptools.build_meta" + +[project] +name = "pympress" +dynamic = ["version"] +description = "A simple and powerful dual-screen PDF reader designed for presentations." +readme = "README.md" +license = "GPL-2.0-or-later" +requires-python = ">=3" +authors = [ + {name = "Cimbali", email = "me@cimba.li"}, + {name = "Thomas Jost"}, + {name = "Christof Rath"}, + {name = "Epithumia"}, +] +keywords = [ + "pdf-viewer", "beamer", "presenter", "slide", "projector", + "pdf-reader", "presentation", "python", "poppler", "gtk", "pygi", "vlc", +] +classifiers = [ + "Development Status :: 5 - Production/Stable", + "Environment :: X11 Applications :: GTK", + "Intended Audience :: Education", + "Intended Audience :: End Users/Desktop", + "Intended Audience :: Information Technology", + "Intended Audience :: Science/Research", + "License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+)", + "Natural Language :: English", + "Natural Language :: French", + "Natural Language :: German", + "Natural Language :: Polish", + "Natural Language :: Spanish", + "Natural Language :: Czech", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Topic :: Multimedia :: Graphics :: Presentation", + "Topic :: Multimedia :: Graphics :: Viewers", +] +dependencies = [ + "watchdog", + "numpy", +] + +[project.optional-dependencies] +docs = [ + "sphinx", + "recommonmark", + "sphinxcontrib-napoleon", + "sphinx-rtd-theme", +] +babel = [ + "babel", + "babelgladeextractor", +] +vlc = [ + "python-vlc", +] + +[project.urls] +Homepage = "https://github.com/Cimbali/pympress/" +Issues = "https://github.com/Cimbali/pympress/issues/" +Documentation = "https://cimbali.github.io/pympress/" +"Source Code" = "https://github.com/Cimbali/pympress/" +Download = "https://github.com/Cimbali/pympress/releases/latest" + +[project.gui-scripts] +pympress = "pympress.__main__:main" + +[tool.setuptools.dynamic] +version = {attr = "pympress.__version__"} + +[tool.setuptools.packages.find] +include = ["pympress", "pympress.*"] + +[tool.setuptools.package-data] +pympress = [ + "share/defaults.conf", + "share/xml/*.glade", + "share/css/*.css", + "share/pixmaps/*.png", + "share/locale/*/LC_MESSAGES/pympress.mo", +] diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000..eaf3c982 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +watchdog +numpy diff --git a/scripts/build_msi_mingw.sh b/scripts/build_msi_mingw.sh index 26fe4e28..29527799 100644 --- a/scripts/build_msi_mingw.sh +++ b/scripts/build_msi_mingw.sh @@ -7,9 +7,9 @@ pacman -S --noprogressbar --noconfirm --needed mingw-w64-$arch-$py-pip mingw-w64 $py -m pip install --disable-pip-version-check --upgrade pip $py -m pip install watchdog python-vlc babel cx_Freeze -$py setup.py compile_catalog -$py setup.py --freeze --$vlc build_exe -$py setup.py --freeze --$vlc bdist_msi --add-to-path True --target-name pympress-`git describe --tags --always --abbrev=0`-$arch.msi +pybabel compile -d pympress/share/locale/ -D pympress --statistics +$py setup.py --$vlc build_exe +$py setup.py --$vlc bdist_msi --add-to-path True --target-name pympress-`git describe --tags --always --abbrev=0`-$arch.msi # Build a zip from the build_exe outputs cd build diff --git a/setup.cfg b/setup.cfg index 91e12521..2c9af717 100644 --- a/setup.cfg +++ b/setup.cfg @@ -1,73 +1,3 @@ -[metadata] -name = pympress -version = attr: pympress.__version__ -keywords = pdf-viewer, beamer, presenter, slide, projector, pdf-reader, presentation, python, poppler, gtk, pygi, vlc -description = A simple and powerful dual-screen PDF reader designed for presentations. -long_description = file: README.md -long_description_content_type = text/markdown -author = Cimbali, Thomas Jost, Christof Rath, Epithumia -author_email = me@cimba.li -url = https://github.com/Cimbali/pympress/ -download_url = https://github.com/Cimbali/pympress/releases/latest -project_urls = - Issues = https://github.com/Cimbali/pympress/issues/ - Documentation = https://cimbali.github.io/pympress/ - Source Code = https://github.com/Cimbali/pympress/ -license = GPLv2 -classifiers = - Development Status :: 5 - Production/Stable - Environment :: X11 Applications :: GTK - Intended Audience :: Education - Intended Audience :: End Users/Desktop - Intended Audience :: Information Technology - Intended Audience :: Science/Research - License :: OSI Approved :: GNU General Public License v2 or later (GPLv2+) - Natural Language :: English - Natural Language :: French - Natural Language :: German - Natural Language :: Polish - Natural Language :: Spanish - Natural Language :: Czech - Operating System :: OS Independent - Programming Language :: Python - Topic :: Multimedia :: Graphics :: Presentation - Topic :: Multimedia :: Graphics :: Viewers - -[options] -packages = - pympress - pympress.media_overlays -python_requires = >=3 -install_requires = - watchdog - enum34;python_version<"3.4" -setup_requires = - babel - -[options.extras_require] -build_sphinx = - sphinx - recommonmark - sphinxcontrib-napoleon - sphinx-rtd-theme -babel = - babel - babelgladeextractor -vlc_video = - python-vlc - -[options.package_data] -pympress = - share/defaults.conf - share/xml/*.glade - share/css/*.css - share/pixmaps/*.png - share/locale/*/LC_MESSAGES/pympress.mo - -[options.entry_points] -gui_scripts = - pympress = pympress.__main__:main - [style] based_on_style = pep8 column_limit = 120 diff --git a/setup.py b/setup.py index 22a822cf..5a9ca8be 100755 --- a/setup.py +++ b/setup.py @@ -20,103 +20,20 @@ # Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, # MA 02110-1301, USA. -""" pympress setup script. +""" pympress cx_Freeze build script. -Mostly wrapping logic for freezing (with cx_Freeze for windows builds). -All configuration is in setup.cfg. +This file is only needed for cx_Freeze frozen builds (Windows MSI/EXE). +Normal installation is handled by pyproject.toml: + + pip install . + pip install -e . + pip install -r requirements.txt """ import os import sys import glob from ctypes.util import find_library -import setuptools - -from setuptools.command.develop import develop -from setuptools.command.install import install -from setuptools.command.bdist_rpm import bdist_rpm - - -def find_index_startstring(haystack, needle, start = 0, stop = sys.maxsize): - """ Return the index of the first string in haystack starting with needle, or raise ValueError if none match. - """ - try: - return next(n for n, v in enumerate(haystack[start:stop], start) if v.startswith(needle)) - except StopIteration: - raise ValueError('No string starts with ' + needle) - - -class PatchedRpmDist(bdist_rpm): - """ Patched bdist rpm to avoid running seds and breaking up the build system - """ - - user_options = bdist_rpm.user_options + [ - ('recommends=', None, "capabilities recommendd by this package"), - ('suggests=', None, "capabilities suggestd by this package"), - ] - - recommends = None - suggests = None - - def finalize_package_data(self): - """ Add recommends/suggests option validation - """ - bdist_rpm.finalize_package_data(self) - - self.ensure_string_list('recommends') - self.ensure_string_list('suggests') - - - def _make_spec_file(self): - # Make the package name python3-pympress instead of pympress - # NB: %{name} evaluates to the RPM package name - spec = [ - line.replace('%{name}', '%{pythonname}') - .replace('define name ', 'define pythonname ') - .replace('Name: %{pythonname}', 'Name: python3-%{pythonname}') - for line in bdist_rpm._make_spec_file(self) if not line.startswith('Group:') - ] - - insert_pos = find_index_startstring(spec, 'Requires:') + 1 - insert = [ - # Define what this package provides in terms of capabilities - 'Provides: python3dist(%{pythonname}) = %{version}', - 'Provides: python%{python3_version}dist(%{pythonname}) = %{version}', - - # For Fedora, this adds python-name to provides if python3 is the default - '%{?python_provide:%python_provide python3-%{pythonname}}', - ] - - if self.recommends: - insert.append('Recommends: ' + ' '.join(self.recommends)) - - if self.suggests: - insert.append('Suggests: ' + ' '.join(self.suggests)) - - # Roll our own py3_dist if it doesn’t exist on this platform, only for requires. - # Also define typelib_deps if we are on suse or mageia, to specify dependencies using typelib capabilities. - return [ - '%define normalize() %(echo %* | tr "[:upper:]_ " "[:lower:]--")', - '%{?!py3_dist:%define py3_dist() (python%{python3_version}dist(%{normalize %1}) or python3-%1)}', - '%{?suse_version:%define typelib_deps 1}', '%{?mga_version:%define typelib_deps 1}', '' - ] + spec[:insert_pos] + insert + spec[insert_pos:] - - - -class PatchedDevelop(develop): - """ Patched installation for development mode to build translations .mo files. """ - def run(self): - """ Run compile_catalog before running (parent) develop command. """ - self.distribution.run_command('compile_catalog') - develop.run(self) - -class PatchedInstall(install): - """Patched installation for installation mode to build translations .mo files. """ - def run(self): - """ Run compile_catalog before running (parent) install command. """ - if not self.single_version_externally_managed: - self.distribution.run_command('compile_catalog') - install.run(self) # All functions listing resources return a list of pairs: (system path, distribution relative path) @@ -165,9 +82,6 @@ def dlls(): libplc4.dll libplds4.dll libpoppler-98.dll libpoppler-cpp-0.dll libpoppler-glib-8.dll libpsl-5.dll \ libpython3.8.dll libstdc++-6.dll libthai-0.dll libtiff-5.dll libunistring-2.dll libwinpthread-1.dll \ libzstd.dll nss3.dll nssutil3.dll smime3.dll' - # these appear superfluous, though unexpectedly so: - # libcairo-2.dll libcairo-gobject-2.dll libfontconfig-1.dll libfreetype-6.dll libiconv-2.dll - # libgettextlib-0-19-8-1.dll libgettextpo-0.dll libgettextsrc-0-19-8-1.dll libintl-8.dll libjasper-4.dll include_files = [] for lib in libs.split(): @@ -235,69 +149,35 @@ def pympress_resources(): if __name__ == '__main__': - options = {} + from cx_Freeze import setup, Executable with open('README.md') as f: - readme = f.readlines() - options['long_description'] = ''.join(readme) - - - # Check our options: whether to freeze, and whether to include VLC resources (DLLs, plugins, etc). - if '--freeze' in sys.argv[1:]: - sys.argv.remove('--freeze') - - print('Using cx_Freeze.setup():', file=sys.stderr) - from cx_Freeze import setup, Executable - - # List all resources we'll distribute - setup_opts = { - **options, - 'options': { - 'build_exe': { - 'includes': [], - 'excludes': ['tkinter'], - 'packages': ['codecs', 'gi', 'vlc', 'watchdog'], - 'include_files': gtk_resources() + dlls() + pympress_resources(), - 'silent': True - } - }, - 'executables': [Executable(os.path.join('pympress', '__main__.py'), targetName='pympress.exe', - base='Win32GUI', shortcutDir='ProgramMenuFolder', shortcutName='pympress', - icon=os.path.join('pympress', 'share', 'pixmaps', 'pympress.ico'))] - } - - if check_vlc_redistribution(): - try: - setup_opts['options']['build_exe']['include_files'] += vlc_resources() - except ImportError: - print('ERROR: VLC python module not available!') - exit(-1) - except Exception as e: - print('ERROR: Cannot include VLC: ' + str(e)) - exit(-1) - - setup(**setup_opts) - else: - # Normal behaviour: use setuptools, load options from setup.cfg - print('Using setuptools.setup():', file=sys.stderr) - - options['cmdclass'] = {'develop': PatchedDevelop, 'install': PatchedInstall, 'bdist_rpm': PatchedRpmDist} - - setuptols_version = tuple(int(n) for n in setuptools.__version__.split('.')) - # older versions are missing out! - if setuptols_version >= (30, 5): - options['data_files'] = [ - ('share/pixmaps/', ['pympress/share/pixmaps/pympress.png']), - ('share/applications/', ['pympress/share/applications/pympress.desktop']), - ] - - setuptools.setup(**options) - - -## -# Local Variables: -# mode: python -# indent-tabs-mode: nil -# py-indent-offset: 4 -# fill-column: 80 -# end: + long_description = f.read() + + setup_opts = { + 'long_description': long_description, + 'options': { + 'build_exe': { + 'includes': [], + 'excludes': ['tkinter'], + 'packages': ['codecs', 'gi', 'vlc', 'watchdog'], + 'include_files': gtk_resources() + dlls() + pympress_resources(), + 'silent': True + } + }, + 'executables': [Executable(os.path.join('pympress', '__main__.py'), targetName='pympress.exe', + base='Win32GUI', shortcutDir='ProgramMenuFolder', shortcutName='pympress', + icon=os.path.join('pympress', 'share', 'pixmaps', 'pympress.ico'))] + } + + if check_vlc_redistribution(): + try: + setup_opts['options']['build_exe']['include_files'] += vlc_resources() + except ImportError: + print('ERROR: VLC python module not available!') + exit(-1) + except Exception as e: + print('ERROR: Cannot include VLC: ' + str(e)) + exit(-1) + + setup(**setup_opts) diff --git a/test_shapes.py b/test_shapes.py new file mode 100644 index 00000000..e3a51471 --- /dev/null +++ b/test_shapes.py @@ -0,0 +1,161 @@ +"""Quick diagnostic test for shape recognition with realistic hand-drawn-like strokes.""" +import math +import numpy as np +from pympress import shape_recognition + +def make_circle(cx, cy, r, n=60, noise=0.01): + """Generate a noisy circle with non-uniform point spacing (like hand drawing).""" + pts = [] + for i in range(n): + # Non-uniform angle spacing to simulate variable drawing speed + t = 2 * math.pi * i / n + np.random.uniform(-0.02, 0.02) + x = cx + r * math.cos(t) + np.random.uniform(-noise, noise) + y = cy + r * math.sin(t) + np.random.uniform(-noise, noise) + pts.append([x, y]) + # Close the stroke (end near start) + pts.append([pts[0][0] + np.random.uniform(-noise, noise), + pts[0][1] + np.random.uniform(-noise, noise)]) + return pts + +def make_ellipse(cx, cy, a, b, n=60, noise=0.01): + """Generate a noisy ellipse.""" + pts = [] + for i in range(n): + t = 2 * math.pi * i / n + np.random.uniform(-0.02, 0.02) + x = cx + a * math.cos(t) + np.random.uniform(-noise, noise) + y = cy + b * math.sin(t) + np.random.uniform(-noise, noise) + pts.append([x, y]) + pts.append([pts[0][0] + np.random.uniform(-noise, noise), + pts[0][1] + np.random.uniform(-noise, noise)]) + return pts + +def make_rectangle(x0, y0, x1, y1, n_per_side=15, noise=0.005): + """Generate a noisy rectangle stroke (going around the 4 sides).""" + pts = [] + corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + for i in range(4): + cx0, cy0 = corners[i] + cx1, cy1 = corners[i + 1] + for j in range(n_per_side): + t = j / n_per_side + x = cx0 + t * (cx1 - cx0) + np.random.uniform(-noise, noise) + y = cy0 + t * (cy1 - cy0) + np.random.uniform(-noise, noise) + pts.append([x, y]) + return pts + +def make_line(x0, y0, x1, y1, n=30, noise=0.003): + """Generate a noisy line.""" + pts = [] + for i in range(n): + t = i / (n - 1) + x = x0 + t * (x1 - x0) + np.random.uniform(-noise, noise) + y = y0 + t * (y1 - y0) + np.random.uniform(-noise, noise) + pts.append([x, y]) + return pts + + +np.random.seed(42) + +print("=== Testing shape recognition ===\n") + +# Test line +pts = make_line(0.1, 0.2, 0.8, 0.7) +result = shape_recognition.recognize_shape(pts) +print(f"Line: {result[0] if result else None}") + +# Test circle +pts = make_circle(0.5, 0.5, 0.15) +result = shape_recognition.recognize_shape(pts) +print(f"Circle: {result[0] if result else None}") + +# Test ellipse +pts = make_ellipse(0.5, 0.5, 0.2, 0.1) +result = shape_recognition.recognize_shape(pts) +print(f"Ellipse: {result[0] if result else None}") + +# Test rectangle +pts = make_rectangle(0.2, 0.2, 0.8, 0.7) +result = shape_recognition.recognize_shape(pts) +print(f"Rectangle: {result[0] if result else None}") + +# Detailed diagnostics +print("\n=== Detailed diagnostics ===\n") + +# Circle diagnostics +pts_raw = make_circle(0.5, 0.5, 0.15) +pts_arr = np.array(pts_raw, dtype=float) +closed = shape_recognition._is_closed(pts_arr) +print(f"Circle: _is_closed = {closed}") +print(f" n_points = {len(pts_arr)}") +d = np.linalg.norm(pts_arr[0] - pts_arr[-1]) +bbox_diag = np.linalg.norm(pts_arr.max(axis=0) - pts_arr.min(axis=0)) +print(f" start-end distance = {d:.6f}") +print(f" bbox diagonal = {bbox_diag:.6f}") +print(f" closure ratio = {d/bbox_diag:.6f} (threshold = {shape_recognition.CLOSURE_DISTANCE_RATIO})") + +if closed: + ell = shape_recognition.fit_ellipse(pts_arr) + print(f" fit_ellipse result: {ell}") + rect = shape_recognition.fit_rectangle(pts_arr) + print(f" fit_rectangle result: {rect}") +else: + print(" NOT CLOSED - ellipse/rect won't be tried") + line = shape_recognition.fit_line(pts_arr) + print(f" fit_line result: {line is not None}") + +# Rectangle diagnostics +pts_raw = make_rectangle(0.2, 0.2, 0.8, 0.7) +pts_arr = np.array(pts_raw, dtype=float) +closed = shape_recognition._is_closed(pts_arr) +print(f"\nRectangle: _is_closed = {closed}") +print(f" n_points = {len(pts_arr)}") +d = np.linalg.norm(pts_arr[0] - pts_arr[-1]) +bbox_diag = np.linalg.norm(pts_arr.max(axis=0) - pts_arr.min(axis=0)) +print(f" start-end distance = {d:.6f}") +print(f" bbox diagonal = {bbox_diag:.6f}") +if bbox_diag > 0: + print(f" closure ratio = {d/bbox_diag:.6f} (threshold = {shape_recognition.CLOSURE_DISTANCE_RATIO})") + +if closed: + rect = shape_recognition.fit_rectangle(pts_arr) + print(f" fit_rectangle result: {rect}") + if rect is None: + # Debug RDP + epsilon = bbox_diag * shape_recognition.RDP_EPSILON_RATIO + simplified = shape_recognition._rdp_simplify(pts_arr, epsilon) + print(f" RDP simplified to {len(simplified)} points (epsilon={epsilon:.6f})") + print(f" Simplified points:\n {simplified}") +else: + print(" NOT CLOSED - rectangle won't be tried") + +# Ellipse diagnostics +pts_raw = make_ellipse(0.5, 0.5, 0.2, 0.1) +pts_arr = np.array(pts_raw, dtype=float) +closed = shape_recognition._is_closed(pts_arr) +print(f"\nEllipse: _is_closed = {closed}") +d = np.linalg.norm(pts_arr[0] - pts_arr[-1]) +bbox_diag = np.linalg.norm(pts_arr.max(axis=0) - pts_arr.min(axis=0)) +print(f" start-end distance = {d:.6f}") +print(f" bbox diagonal = {bbox_diag:.6f}") +if bbox_diag > 0: + print(f" closure ratio = {d/bbox_diag:.6f} (threshold = {shape_recognition.CLOSURE_DISTANCE_RATIO})") + +if closed: + ell = shape_recognition.fit_ellipse(pts_arr) + print(f" fit_ellipse result: {ell}") + if ell is None: + # Debug individually + centroid = pts_arr.mean(axis=0) + centered = pts_arr - centroid + cov = np.cov(centered.T) + eigenvalues, eigenvectors = np.linalg.eigh(cov) + idx = np.argsort(eigenvalues)[::-1] + eigenvalues = eigenvalues[idx] + eigenvectors = eigenvectors[:, idx] + transformed = centered @ eigenvectors + semi_a = float(np.percentile(np.abs(transformed[:, 0]), 95)) + semi_b = float(np.percentile(np.abs(transformed[:, 1]), 95)) + print(f" semi_a={semi_a:.6f}, semi_b={semi_b:.6f}") + radial = (transformed[:, 0] / semi_a) ** 2 + (transformed[:, 1] / semi_b) ** 2 + radial_std = float(np.std(radial - 1.0)) + print(f" radial_std = {radial_std:.6f} (threshold = {shape_recognition.ELLIPSE_RADIAL_STD_RATIO})") diff --git a/test_shapes_debug.py b/test_shapes_debug.py new file mode 100644 index 00000000..e1bdce60 --- /dev/null +++ b/test_shapes_debug.py @@ -0,0 +1,92 @@ +"""Debug specific shape recognition failures — now testing fixes.""" +import math +import numpy as np +from pympress import shape_recognition + +np.random.seed(123) + +def test(name, pts): + result = shape_recognition.recognize_shape(pts) + status = result[0] if result else 'NONE' + print(" {}: {}".format(name, status)) + return result + +print("=== Shape tests with realistic hand-drawn noise ===\n") + +# Quick circle (12pts) +pts = [] +for i in range(12): + t = 2 * math.pi * i / 12 + pts.append([0.5 + 0.12 * math.cos(t) + np.random.uniform(-0.015, 0.015), + 0.5 + 0.12 * math.sin(t) + np.random.uniform(-0.015, 0.015)]) +pts.append([pts[0][0] + np.random.uniform(-0.01, 0.01), + pts[0][1] + np.random.uniform(-0.01, 0.01)]) +test("Quick circle (12pts, noise=0.015)", pts) + +# Quick circle (20pts) +np.random.seed(42) +pts = [] +for i in range(20): + t = 2 * math.pi * i / 20 + pts.append([0.5 + 0.15 * math.cos(t) + np.random.uniform(-0.02, 0.02), + 0.5 + 0.15 * math.sin(t) + np.random.uniform(-0.02, 0.02)]) +pts.append([pts[0][0] + np.random.uniform(-0.02, 0.02), + pts[0][1] + np.random.uniform(-0.02, 0.02)]) +test("Quick circle (20pts, noise=0.02)", pts) + +# Sloppy ellipse (25pts) +np.random.seed(123) +pts = [] +for i in range(25): + t = 2 * math.pi * i / 25 + pts.append([0.5 + 0.25 * math.cos(t) + np.random.uniform(-0.025, 0.025), + 0.5 + 0.1 * math.sin(t) + np.random.uniform(-0.025, 0.025)]) +pts.append([pts[0][0] + 0.01, pts[0][1] + 0.01]) +test("Sloppy ellipse (25pts, noise=0.025)", pts) + +# Sloppy rectangle (no explicit closing point) +np.random.seed(123) +pts = [] +corners = [(0.2, 0.3), (0.8, 0.3), (0.8, 0.7), (0.2, 0.7), (0.2, 0.3)] +for i in range(4): + cx0, cy0 = corners[i] + cx1, cy1 = corners[i + 1] + for j in range(10): + t = j / 10 + pts.append([cx0 + t * (cx1 - cx0) + np.random.uniform(-0.01, 0.01), + cy0 + t * (cy1 - cy0) + np.random.uniform(-0.01, 0.01)]) +test("Sloppy rect (40pts, noise=0.01)", pts) + +# Rectangle starting mid-side +np.random.seed(42) +pts = [] +# Start at middle of top side +side_points = [ + [(0.5, 0.2), (0.8, 0.2)], + [(0.8, 0.2), (0.8, 0.7)], + [(0.8, 0.7), (0.2, 0.7)], + [(0.2, 0.7), (0.2, 0.2)], + [(0.2, 0.2), (0.5, 0.2)], +] +for s0, s1 in side_points: + for j in range(8): + t = j / 8 + pts.append([s0[0] + t * (s1[0] - s0[0]) + np.random.uniform(-0.005, 0.005), + s0[1] + t * (s1[1] - s0[1]) + np.random.uniform(-0.005, 0.005)]) +test("Rect mid-side start (40pts, noise=0.005)", pts) + +# Clean line (should still work) +np.random.seed(42) +pts = [] +for i in range(30): + t = i / 29 + pts.append([0.1 + t * 0.7 + np.random.uniform(-0.003, 0.003), + 0.2 + t * 0.5 + np.random.uniform(-0.003, 0.003)]) +test("Clean line (30pts)", pts) + +# Random noise (should NOT recognize) +np.random.seed(42) +pts = [[np.random.uniform(0.2, 0.8), np.random.uniform(0.2, 0.8)] for _ in range(30)] +test("Random noise (30pts)", pts) + +print("\nAll tests complete.") diff --git a/tests/test_shape_recognition.py b/tests/test_shape_recognition.py new file mode 100644 index 00000000..b17ec4d0 --- /dev/null +++ b/tests/test_shape_recognition.py @@ -0,0 +1,591 @@ +# -*- coding: utf-8 -*- +""" +Test suite for pympress.shape_recognition + +Simulates realistic hand-drawn strokes in the normalised coordinate space +used by pympress (x in [0,1] for slide width, y in [0,1] for slide height). + +On a 16:9 slide a circle drawn on-screen has *unequal* x and y extents in +this space, so the tests include aspect-ratio effects. + +Run: python tests/test_shape_recognition.py +""" +from __future__ import print_function, unicode_literals + +import copy +import math +import sys +import numpy as np + +# Adjust path so we can import pympress from the repo root +sys.path.insert(0, '.') +from pympress import shape_recognition + + +# --------------------------------------------------------------------------- +# Stroke generators — produce lists of [x, y] as pympress stores them +# --------------------------------------------------------------------------- + +def generate_circle(cx, cy, r_pixels, ww, wh, n=50, noise_px=2, closure_gap_px=0): + """Circle of radius *r_pixels* drawn on a *ww x wh* widget, stored in + normalised coords. + + Args: + closure_gap_px: extra gap (in pixels) between first and last point + to simulate imperfect closure. + """ + pts = [] + for i in range(n): + t = 2 * math.pi * i / n + px = cx * ww + r_pixels * math.cos(t) + np.random.uniform(-noise_px, noise_px) + py = cy * wh + r_pixels * math.sin(t) + np.random.uniform(-noise_px, noise_px) + pts.append([px / ww, py / wh]) + # Closing point — near start but with optional gap + pts.append([pts[0][0] + np.random.uniform(-noise_px / ww, noise_px / ww) + closure_gap_px / ww, + pts[0][1] + np.random.uniform(-noise_px / wh, noise_px / wh) + closure_gap_px / wh]) + return pts + + +def generate_circle_tuples(cx, cy, r_pixels, ww, wh, n=50, noise_px=2): + """Same as generate_circle but returns tuples (as get_slide_point does).""" + pts = generate_circle(cx, cy, r_pixels, ww, wh, n, noise_px) + return [tuple(p) for p in pts] + + +def generate_ellipse(cx, cy, rx_pixels, ry_pixels, ww, wh, n=50, noise_px=2): + """Ellipse with given pixel radii on a ww x wh widget.""" + pts = [] + for i in range(n): + t = 2 * math.pi * i / n + px = cx * ww + rx_pixels * math.cos(t) + np.random.uniform(-noise_px, noise_px) + py = cy * wh + ry_pixels * math.sin(t) + np.random.uniform(-noise_px, noise_px) + pts.append([px / ww, py / wh]) + pts.append([pts[0][0] + np.random.uniform(-noise_px / ww, noise_px / ww), + pts[0][1] + np.random.uniform(-noise_px / wh, noise_px / wh)]) + return pts + + +def generate_rectangle(cx, cy, half_w_px, half_h_px, ww, wh, n_per_side=12, noise_px=2): + """Rectangle with given pixel half-widths, traced continuously.""" + x0, y0 = cx * ww - half_w_px, cy * wh - half_h_px + x1, y1 = cx * ww + half_w_px, cy * wh + half_h_px + corners = [(x0, y0), (x1, y0), (x1, y1), (x0, y1), (x0, y0)] + pts = [] + for i in range(4): + ax, ay = corners[i] + bx, by = corners[i + 1] + for j in range(n_per_side): + t = j / n_per_side + px = ax + t * (bx - ax) + np.random.uniform(-noise_px, noise_px) + py = ay + t * (by - ay) + np.random.uniform(-noise_px, noise_px) + pts.append([px / ww, py / wh]) + return pts + + +def generate_line(x0, y0, x1, y1, n=30, noise=0.003): + """Simple line in normalised coords.""" + pts = [] + for i in range(n): + t = i / (n - 1) + pts.append([x0 + t * (x1 - x0) + np.random.uniform(-noise, noise), + y0 + t * (y1 - y0) + np.random.uniform(-noise, noise)]) + return pts + + +def generate_circle_variable_speed(cx, cy, r_pixels, ww, wh, n=50, noise_px=2): + """Circle with non-uniform angular spacing to simulate variable drawing + speed — more points where the user slows down (at top), fewer where fast.""" + pts = [] + angles = [] + for i in range(n): + base_t = 2 * math.pi * i / n + # Slow at top (t ~ pi/2), fast at bottom (t ~ 3pi/2) + speed_factor = 1.0 + 0.5 * math.sin(base_t) + angles.append(base_t) + + for t in angles: + px = cx * ww + r_pixels * math.cos(t) + np.random.uniform(-noise_px, noise_px) + py = cy * wh + r_pixels * math.sin(t) + np.random.uniform(-noise_px, noise_px) + pts.append([px / ww, py / wh]) + pts.append([pts[0][0] + np.random.uniform(-noise_px / ww, noise_px / ww), + pts[0][1] + np.random.uniform(-noise_px / wh, noise_px / wh)]) + return pts + + +# --------------------------------------------------------------------------- +# Diagnostic helpers +# --------------------------------------------------------------------------- + +def diagnose(pts, label): + """Run shape_recognition.recognize_shape and print detailed diagnostics.""" + arr = np.array(pts, dtype=float) + n = len(arr) + closed = shape_recognition._is_closed(arr) + d = np.linalg.norm(arr[0] - arr[-1]) + bbox = arr.max(axis=0) - arr.min(axis=0) + bbox_diag = np.linalg.norm(bbox) + closure_ratio = d / bbox_diag if bbox_diag > 1e-9 else float('inf') + + result = shape_recognition.recognize_shape(pts) + shape = result[0] if result else 'NONE' + + print("--- {} ---".format(label)) + print(" points={}, closed={}, closure_ratio={:.4f} (thr={})".format( + n, closed, closure_ratio, shape_recognition.CLOSURE_DISTANCE_RATIO)) + print(" bbox_size=({:.4f}, {:.4f}), bbox_diag={:.4f}".format(bbox[0], bbox[1], bbox_diag)) + + if closed: + # Ellipse diagnostics + centroid = arr.mean(axis=0) + centered = arr - centroid + cov = np.cov(centered.T) + eigenvalues, eigenvectors = np.linalg.eigh(cov) + idx = np.argsort(eigenvalues)[::-1] + eigenvalues = eigenvalues[idx] + eigenvectors = eigenvectors[:, idx] + transformed = centered @ eigenvectors + semi_a = float(np.percentile(np.abs(transformed[:, 0]), 98)) + semi_b = float(np.percentile(np.abs(transformed[:, 1]), 98)) + if semi_a > 1e-9 and semi_b > 1e-9: + radial = (transformed[:, 0] / semi_a) ** 2 + (transformed[:, 1] / semi_b) ** 2 + radial_std = float(np.std(radial - 1.0)) + axis_ratio = min(semi_a, semi_b) / max(semi_a, semi_b) + print(" ellipse: semi_a={:.5f}, semi_b={:.5f}, axis_ratio={:.3f}, radial_std={:.4f} (thr={})".format( + semi_a, semi_b, axis_ratio, radial_std, shape_recognition.ELLIPSE_RADIAL_STD_RATIO)) + + # Rectangle diagnostics + epsilon = bbox_diag * shape_recognition.RDP_EPSILON_RATIO + pts_closed = np.vstack([arr, arr[0:1]]) + simplified = shape_recognition._rdp_simplify(pts_closed, epsilon) + if np.linalg.norm(simplified[0] - simplified[-1]) <= epsilon: + simplified_no_dup = simplified[:-1] + else: + simplified_no_dup = simplified + vertices = shape_recognition._remove_collinear(simplified_no_dup, epsilon) + print(" rectangle: rdp_pts={}, after_collinear={}, epsilon={:.6f}".format( + len(simplified), len(vertices), epsilon)) + if len(vertices) == 4: + angles = [] + for i in range(len(vertices)): + v1 = vertices[(i + 1) % len(vertices)] - vertices[i] + v2 = vertices[(i - 1) % len(vertices)] - vertices[i] + angles.append(shape_recognition._angle_between(v1, v2)) + print(" rectangle: angles={}".format(["%.1f" % a for a in angles])) + else: + # Line diagnostics + line = shape_recognition.fit_line(arr) + print(" line: fit={}".format(line is not None)) + + print(" >> RESULT: {}".format(shape)) + return result + + +# --------------------------------------------------------------------------- +# Test framework +# --------------------------------------------------------------------------- + +PASS = 0 +FAIL = 0 + + +def check(result, expected_type, label): + global PASS, FAIL + got = result[0] if result else 'NONE' + ok = got == expected_type + if ok: + PASS += 1 + print(" [PASS] {} -> {}".format(label, got)) + else: + FAIL += 1 + print(" [FAIL] {} -> {} (expected {})".format(label, got, expected_type)) + return ok + + +def check_any(result, expected_types, label): + """Check result matches any of the given shape types.""" + global PASS, FAIL + got = result[0] if result else 'NONE' + ok = got in expected_types + if ok: + PASS += 1 + print(" [PASS] {} -> {}".format(label, got)) + else: + FAIL += 1 + print(" [FAIL] {} -> {} (expected one of {})".format(label, got, expected_types)) + return ok + + +# --------------------------------------------------------------------------- +# Test suites +# --------------------------------------------------------------------------- + +def test_lines(): + """Test line detection.""" + print("\n== LINES ==") + + pts = generate_line(0.1, 0.2, 0.8, 0.7) + r = diagnose(pts, "Diagonal line") + check(r, 'line', "diagonal line") + + pts = generate_line(0.1, 0.5, 0.9, 0.5) + r = diagnose(pts, "Horizontal line") + check(r, 'line', "horizontal line") + + pts = generate_line(0.5, 0.1, 0.5, 0.9) + r = diagnose(pts, "Vertical line") + check(r, 'line', "vertical line") + + pts = generate_line(0.3, 0.3, 0.7, 0.7, n=10, noise=0.005) + r = diagnose(pts, "Short line, few points") + check(r, 'line', "short line few pts") + + # Very short line (near-minimum points) + pts = generate_line(0.4, 0.4, 0.6, 0.6, n=4, noise=0.002) + r = diagnose(pts, "Minimal line (4pts)") + check(r, 'line', "minimal line") + + +def test_circles_16_9(): + """Test circle detection on 16:9 display (aspect ratio distortion).""" + print("\n== CIRCLES (16:9 display, 1920x1080) ==") + WW, WH = 1920, 1080 + + # Small circle (80px radius, 40pts) + pts = generate_circle(0.5, 0.5, 80, WW, WH, n=40, noise_px=3) + r = diagnose(pts, "Small circle 80px") + check_any(r, ('circle', 'ellipse'), "small circle 80px") + + # Medium circle (150px, 60pts) + pts = generate_circle(0.5, 0.5, 150, WW, WH, n=60, noise_px=3) + r = diagnose(pts, "Medium circle 150px") + check_any(r, ('circle', 'ellipse'), "medium circle 150px") + + # Large circle (250px, 80pts) + pts = generate_circle(0.5, 0.5, 250, WW, WH, n=80, noise_px=4) + r = diagnose(pts, "Large circle 250px") + check_any(r, ('circle', 'ellipse'), "large circle 250px") + + # Quick circle (few points, fast drawing ~ 60Hz for 0.25s) + pts = generate_circle(0.5, 0.5, 120, WW, WH, n=15, noise_px=4) + r = diagnose(pts, "Quick circle 120px (15pts)") + check_any(r, ('circle', 'ellipse'), "quick circle 15pts") + + # Very quick circle (8 pts — near MIN_POINTS_SHAPE) + pts = generate_circle(0.5, 0.5, 120, WW, WH, n=8, noise_px=3) + r = diagnose(pts, "Very quick circle 120px (8pts)") + check_any(r, ('circle', 'ellipse'), "very quick circle 8pts") + + # Messy circle (high noise) + pts = generate_circle(0.5, 0.5, 150, WW, WH, n=50, noise_px=10) + r = diagnose(pts, "Messy circle 150px (noise=10px)") + check_any(r, ('circle', 'ellipse'), "messy circle") + + # Variable speed circle + pts = generate_circle_variable_speed(0.5, 0.5, 150, WW, WH, n=50, noise_px=3) + r = diagnose(pts, "Variable speed circle 150px") + check_any(r, ('circle', 'ellipse'), "variable speed circle") + + # Circle with poor closure (20px gap) + pts = generate_circle(0.5, 0.5, 120, WW, WH, n=40, noise_px=3, closure_gap_px=20) + r = diagnose(pts, "Circle with 20px closure gap") + check_any(r, ('circle', 'ellipse'), "circle 20px gap") + + # Circle with large closure gap (should still be < 20% of diagonal) + pts = generate_circle(0.5, 0.5, 150, WW, WH, n=50, noise_px=3, closure_gap_px=50) + r = diagnose(pts, "Circle with 50px closure gap") + check_any(r, ('circle', 'ellipse'), "circle 50px gap") + + +def test_circles_4_3(): + """Test circle detection on 4:3 display.""" + print("\n== CIRCLES (4:3 display, 1024x768) ==") + WW, WH = 1024, 768 + + pts = generate_circle(0.5, 0.5, 100, WW, WH, n=50, noise_px=2) + r = diagnose(pts, "Circle 100px on 4:3") + check_any(r, ('circle', 'ellipse'), "circle 4:3") + + +def test_circles_ideal(): + """Test circle detection on square display (no distortion).""" + print("\n== CIRCLES (ideal, 1000x1000) ==") + WW, WH = 1000, 1000 + + pts = generate_circle(0.5, 0.5, 100, WW, WH, n=50, noise_px=2) + r = diagnose(pts, "Circle ideal 100px") + check(r, 'circle', "ideal circle") + + pts = generate_circle(0.5, 0.5, 100, WW, WH, n=20, noise_px=5) + r = diagnose(pts, "Sloppy circle 100px (20pts, noise=5)") + check_any(r, ('circle', 'ellipse'), "sloppy circle") + + +def test_ellipses(): + """Test ellipse detection.""" + print("\n== ELLIPSES ==") + WW, WH = 1920, 1080 + + pts = generate_ellipse(0.5, 0.5, 200, 100, WW, WH, n=60, noise_px=3) + r = diagnose(pts, "Wide ellipse 200x100px on 16:9") + check_any(r, ('circle', 'ellipse'), "wide ellipse 16:9") + + pts = generate_ellipse(0.5, 0.5, 80, 200, WW, WH, n=60, noise_px=3) + r = diagnose(pts, "Tall ellipse 80x200px on 16:9") + check_any(r, ('circle', 'ellipse'), "tall ellipse 16:9") + + pts = generate_ellipse(0.5, 0.5, 150, 80, 1000, 1000, n=50, noise_px=2) + r = diagnose(pts, "Ideal ellipse 150x80px") + check(r, 'ellipse', "ideal ellipse") + + # Very eccentric ellipse (4:1 ratio) + pts = generate_ellipse(0.5, 0.5, 300, 75, WW, WH, n=60, noise_px=3) + r = diagnose(pts, "Very eccentric ellipse 300x75px") + check_any(r, ('circle', 'ellipse'), "very eccentric ellipse") + + # Sloppy ellipse (few points, more noise) + pts = generate_ellipse(0.5, 0.5, 150, 80, WW, WH, n=20, noise_px=8) + r = diagnose(pts, "Sloppy ellipse 150x80px (20pts, noise=8px)") + check_any(r, ('circle', 'ellipse'), "sloppy ellipse") + + +def test_rectangles(): + """Test rectangle detection.""" + print("\n== RECTANGLES ==") + WW, WH = 1920, 1080 + + pts = generate_rectangle(0.5, 0.5, 200, 120, WW, WH, n_per_side=12, noise_px=3) + r = diagnose(pts, "Rectangle 200x120px on 16:9") + check(r, 'rectangle', "rectangle 16:9") + + pts = generate_rectangle(0.5, 0.5, 100, 100, WW, WH, n_per_side=12, noise_px=3) + r = diagnose(pts, "Square 100x100px on 16:9 (stretched in norm coords)") + check(r, 'rectangle', "square 16:9") + + pts = generate_rectangle(0.5, 0.5, 400, 250, WW, WH, n_per_side=20, noise_px=4) + r = diagnose(pts, "Large rectangle 400x250px") + check(r, 'rectangle', "large rectangle") + + pts = generate_rectangle(0.5, 0.5, 150, 100, 1000, 1000, n_per_side=12, noise_px=2) + r = diagnose(pts, "Ideal rectangle 150x100px") + check(r, 'rectangle', "ideal rectangle") + + pts = generate_rectangle(0.5, 0.5, 150, 100, 1000, 1000, n_per_side=8, noise_px=6) + r = diagnose(pts, "Sloppy rectangle (8pts/side, noise=6px)") + check(r, 'rectangle', "sloppy rectangle") + + # Quick rectangle (few points per side) + pts = generate_rectangle(0.5, 0.5, 200, 150, WW, WH, n_per_side=5, noise_px=3) + r = diagnose(pts, "Quick rectangle (5pts/side)") + check(r, 'rectangle', "quick rectangle") + + +def test_negative(): + """Test that non-shapes are rejected.""" + print("\n== NEGATIVE CASES ==") + + pts = [[np.random.uniform(0.2, 0.8), np.random.uniform(0.2, 0.8)] for _ in range(30)] + r = diagnose(pts, "Random noise 30pts") + check(r, 'NONE', "random noise") + + pts = [] + for i in range(30): + t = i / 29 + pts.append([0.1 + 0.8 * t, 0.5 + 0.05 * ((-1) ** i)]) + r = diagnose(pts, "Zigzag") + check(r, 'NONE', "zigzag") + + pts = [[0.1, 0.1], [0.9, 0.9]] + r = diagnose(pts, "Only 2 points") + check(r, 'NONE', "2 points") + + # Triangle (closed, but not a rectangle or ellipse) + pts = [] + for i in range(15): + pts.append([0.3 + 0.4 * i / 14, 0.3]) # bottom + for i in range(15): + pts.append([0.7 - 0.2 * i / 14, 0.3 + 0.4 * i / 14]) # right side + for i in range(15): + pts.append([0.5 - 0.2 * i / 14, 0.7 - 0.4 * i / 14]) # left side + r = diagnose(pts, "Triangle") + check(r, 'NONE', "triangle") + + +def test_point_types(): + """Test that both list and tuple point formats work (pympress stores tuples).""" + print("\n== POINT FORMAT TESTS ==") + WW, WH = 1920, 1080 + + # Tuples (as returned by get_slide_point) + pts = generate_circle_tuples(0.5, 0.5, 120, WW, WH, n=40, noise_px=3) + r = diagnose(pts, "Circle as tuples") + check_any(r, ('circle', 'ellipse'), "circle tuples") + + # Mixed lists and tuples + pts_list = generate_circle(0.5, 0.5, 120, WW, WH, n=40, noise_px=3) + pts_mixed = [tuple(p) if i % 2 == 0 else p for i, p in enumerate(pts_list)] + r = diagnose(pts_mixed, "Circle mixed tuples/lists") + check_any(r, ('circle', 'ellipse'), "circle mixed") + + +def test_scribble_conversion(): + """Integration test: verify that _try_recognize_shape correctly converts + the scribble data structure for each shape type.""" + print("\n== SCRIBBLE CONVERSION (integration) ==") + WW, WH = 1920, 1080 + + color = (1.0, 0.0, 0.0, 1.0) + width = 3.0 + + # --- Circle → ellipse scribble --- + pts = generate_circle(0.5, 0.5, 120, WW, WH, n=40, noise_px=3) + result = shape_recognition.recognize_shape(pts) + if result and result[0] in ('circle', 'ellipse'): + scribble = ["segment", color, width, pts, [[0, 0], [1, 1]]] + old_state = copy.deepcopy(scribble[:]) + shape_type, params = result + + if shape_type == 'circle': + center, radius = params + corner1 = [center[0] - radius, center[1] - radius] + corner2 = [center[0] + radius, center[1] + radius] + else: + center, half_w, half_h = params + corner1 = [center[0] - half_w, center[1] - half_h] + corner2 = [center[0] + half_w, center[1] + half_h] + + scribble[0] = "ellipse" + scribble[3] = [corner1, corner2] + scribble[4] = [corner1[:], corner2[:]] + del scribble[5:] + scribble.append((0, 0, 0, 0)) + + # Verify structure + assert scribble[0] == "ellipse", "type should be ellipse" + assert len(scribble[3]) == 2, "should have 2 corner points" + assert len(scribble) == 6, "should have 6 elements (incl fill_color)" + c1, c2 = scribble[3] + assert c1[0] < c2[0] or abs(c1[0] - c2[0]) < 0.01, "x0 should be <= x1" + assert c1[1] < c2[1] or abs(c1[1] - c2[1]) < 0.01, "y0 should be <= y1" + # Rendering check: ellipse needs non-zero dimensions + p0 = (c1[0] * WW, c1[1] * WH) + p1 = (c2[0] * WW, c2[1] * WH) + assert abs(p1[0] - p0[0]) > 1, "x extent should be > 1px, got {}".format(abs(p1[0] - p0[0])) + assert abs(p1[1] - p0[1]) > 1, "y extent should be > 1px, got {}".format(abs(p1[1] - p0[1])) + + global PASS + PASS += 1 + print(" [PASS] circle -> ellipse scribble (dims: {:.0f}x{:.0f}px)".format( + abs(p1[0] - p0[0]), abs(p1[1] - p0[1]))) + else: + global FAIL + FAIL += 1 + print(" [FAIL] circle not recognized for scribble conversion test") + + # --- Rectangle → box scribble --- + pts = generate_rectangle(0.5, 0.5, 200, 120, WW, WH, n_per_side=12, noise_px=3) + result = shape_recognition.recognize_shape(pts) + if result and result[0] == 'rectangle': + scribble = ["segment", color, width, pts, [[0, 0], [1, 1]]] + corner1, corner2 = result[1] + scribble[0] = "box" + scribble[3] = [corner1, corner2] + scribble[4] = [corner1[:], corner2[:]] + del scribble[5:] + scribble.append((0, 0, 0, 0)) + + assert scribble[0] == "box" + assert len(scribble[3]) == 2 + assert len(scribble) == 6 + PASS += 1 + print(" [PASS] rectangle -> box scribble") + else: + FAIL += 1 + print(" [FAIL] rectangle not recognized for scribble conversion test") + + # --- Line → segment scribble --- + pts = generate_line(0.1, 0.2, 0.8, 0.7) + result = shape_recognition.recognize_shape(pts) + if result and result[0] == 'line': + scribble = ["segment", color, width, pts, [[0, 0], [1, 1]]] + start, end = result[1] + scribble[0] = "segment" + scribble[3] = [start, end] + scribble[4] = [list(map(min, zip(start, end))), list(map(max, zip(start, end)))] + del scribble[5:] + + assert scribble[0] == "segment" + assert len(scribble[3]) == 2 + assert len(scribble) == 5, "segment should have 5 elements, got {}".format(len(scribble)) + PASS += 1 + print(" [PASS] line -> segment scribble") + else: + FAIL += 1 + print(" [FAIL] line not recognized for scribble conversion test") + + +def test_edge_cases(): + """Test edge cases and boundary conditions.""" + print("\n== EDGE CASES ==") + WW, WH = 1920, 1080 + + # Exactly MIN_POINTS_SHAPE points for a circle + pts = generate_circle(0.5, 0.5, 120, WW, WH, n=shape_recognition.MIN_POINTS_SHAPE - 1, noise_px=2) + r = diagnose(pts, "Circle with exactly MIN_POINTS_SHAPE pts ({})".format(shape_recognition.MIN_POINTS_SHAPE)) + check_any(r, ('circle', 'ellipse'), "min-points circle") + + # Very small shape (20px radius) + pts = generate_circle(0.5, 0.5, 20, WW, WH, n=30, noise_px=2) + r = diagnose(pts, "Tiny circle 20px") + check_any(r, ('circle', 'ellipse'), "tiny circle") + + # Shape near edge of screen + pts = generate_circle(0.05, 0.05, 50, WW, WH, n=30, noise_px=2) + r = diagnose(pts, "Circle near corner (cx=0.05, cy=0.05)") + check_any(r, ('circle', 'ellipse'), "corner circle") + + # Shape drawn over most of the slide + pts = generate_circle(0.5, 0.5, 450, WW, WH, n=80, noise_px=5) + r = diagnose(pts, "Huge circle 450px") + check_any(r, ('circle', 'ellipse'), "huge circle") + + # Very small rectangle (noise proportional to size) + pts = generate_rectangle(0.5, 0.5, 30, 20, WW, WH, n_per_side=8, noise_px=1) + r = diagnose(pts, "Tiny rectangle 30x20px (noise=1px)") + check(r, 'rectangle', "tiny rectangle") + + +def run_tests(): + global PASS, FAIL + + np.random.seed(42) + + print("=" * 60) + print("SHAPE RECOGNITION TEST SUITE") + print("=" * 60) + + test_lines() + test_circles_16_9() + test_circles_4_3() + test_circles_ideal() + test_ellipses() + test_rectangles() + test_negative() + test_point_types() + test_scribble_conversion() + test_edge_cases() + + print("\n" + "=" * 60) + total = PASS + FAIL + print("RESULTS: {}/{} passed, {} failed".format(PASS, total, FAIL)) + if FAIL > 0: + print("STATUS: SOME TESTS FAILED") + else: + print("STATUS: ALL TESTS PASSED") + print("=" * 60) + + return FAIL == 0 + + +if __name__ == '__main__': + ok = run_tests() + sys.exit(0 if ok else 1)