diff --git a/.github/workflows/publish.yml b/.github/workflows/publish.yml index 51fa5d1..e8c3354 100644 --- a/.github/workflows/publish.yml +++ b/.github/workflows/publish.yml @@ -1,112 +1,87 @@ -name: Build and Publish +name: Native installers and releases on: push: - branches: [ master ] - tags: [ 'v*' ] + branches: [main, master] + tags: ['v*'] pull_request: - branches: [ master ] + branches: [main, master] + workflow_dispatch: -jobs: - build-windows: - runs-on: windows-latest - steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - cache: 'pip' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller +permissions: + contents: read - - name: Build with PyInstaller - run: | - pyinstaller ethoscore.spec - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: windows-exe - path: dist/ethoscore.exe - - build-linux: - runs-on: ubuntu-22.04 +jobs: + build: + strategy: + fail-fast: false + matrix: + include: + - runner: windows-latest + platform: Windows-x86_64 + - runner: ubuntu-22.04 + platform: Linux-x86_64 + - runner: macos-15 + platform: macOS-arm64 + - runner: macos-15-intel + platform: macOS-x86_64 + runs-on: ${{ matrix.runner }} steps: - - uses: actions/checkout@v3 - - - name: Set up Python - uses: actions/setup-python@v4 - with: - python-version: '3.10' - cache: 'pip' - - - name: Install dependencies - run: | - python -m pip install --upgrade pip - pip install -r requirements.txt - pip install pyinstaller - - - name: Build with PyInstaller - run: | - pyinstaller ethoscore.spec - - - name: Create AppImage - run: | - sudo apt-get update - sudo apt-get install -y libfuse2 - wget https://github.com/linuxdeploy/linuxdeploy/releases/download/continuous/linuxdeploy-x86_64.AppImage - chmod +x linuxdeploy-x86_64.AppImage - - # Prepare AppDir structure - mkdir -p AppDir/usr/bin - cp dist/ethoscore AppDir/usr/bin/ - chmod +x AppDir/usr/bin/ethoscore - cp ethoscore.desktop AppDir/ - cp assets/controller-logo.svg AppDir/ethoscore.svg - - # Run linuxdeploy - export ARCH=x86_64 - ./linuxdeploy-x86_64.AppImage --appimage-extract-and-run --appdir AppDir -e AppDir/usr/bin/ethoscore -d ethoscore.desktop -i AppDir/ethoscore.svg --output appimage - - # Rename output - mv Ethoscore-x86_64.AppImage dist/ethoscore.AppImage - - - name: Upload Artifact - uses: actions/upload-artifact@v4 - with: - name: linux-appimage - path: dist/ethoscore.AppImage + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 + with: + python-version: '3.12' + cache: pip + - name: Install native Linux Qt runtime libraries + if: runner.os == 'Linux' + run: | + sudo apt-get update + sudo apt-get install -y libegl1 libopengl0 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 + - name: Install application and build dependencies + run: python -m pip install -r requirements.txt 'pyinstaller>=6.11' + - name: Validate installer transactions and malicious-archive rejection + run: python -m unittest installer.test_backend -v + - name: Validate arena detection, export, and annotation workflows + env: + QT_QPA_PLATFORM: offscreen + SDL_AUDIODRIVER: dummy + run: python -m unittest discover -s tests -v + - name: Build native application and graphical setup + env: + ETHOSCORE_BUILD_VERSION: ${{ github.ref_name }}-${{ github.sha }} + run: python -m installer.build --version "$ETHOSCORE_BUILD_VERSION" + shell: bash + - name: Smoke-test the actual native setup and installed application + env: + QT_QPA_PLATFORM: offscreen + SDL_AUDIODRIVER: dummy + run: python -m installer.native_smoke + - name: Upload native installers and update payloads + uses: actions/upload-artifact@v4 + with: + name: Ethoscore-${{ matrix.platform }} + path: dist/releases/* + if-no-files-found: error publish: - needs: [build-windows, build-linux] - if: github.event_name == 'push' && (github.ref == 'refs/heads/master' || startsWith(github.ref, 'refs/tags/')) + needs: build + if: github.event_name == 'push' + permissions: + contents: write runs-on: ubuntu-latest steps: - - name: Download Windows Artifact - uses: actions/download-artifact@v4 - with: - name: windows-exe - path: ./publish - - - name: Download Linux Artifact - uses: actions/download-artifact@v4 - with: - name: linux-appimage - path: ./publish - - - name: Create Release - uses: ncipollo/release-action@v1 - with: - artifacts: "./publish/*" - token: ${{ secrets.GITHUB_TOKEN }} - generateReleaseNotes: true - allowUpdates: true - tag: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'latest' }} - name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'Latest Build' }} - prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} + - uses: actions/download-artifact@v4 + with: + pattern: Ethoscore-* + path: publish + merge-multiple: true + - name: Publish native installer release + uses: ncipollo/release-action@v1 + with: + artifacts: publish/* + generateReleaseNotes: true + allowUpdates: true + replacesArtifacts: true + tag: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'latest' }} + name: ${{ startsWith(github.ref, 'refs/tags/') && github.ref_name || 'Latest build' }} + prerelease: ${{ !startsWith(github.ref, 'refs/tags/') }} diff --git a/.gitignore b/.gitignore index 0bafef6..a15c730 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,10 @@ roadmap.md __pycache__/ *.pyc -behaviors.csv \ No newline at end of file +behaviors.csv +SESSION_MEMORY.md +build/ +dist/ +.test-artifacts/ +tests/artifacts/ +installer-build.log diff --git a/README.md b/README.md index 2d985cd..df0197d 100644 --- a/README.md +++ b/README.md @@ -18,7 +18,18 @@ Ethoscore is a video annotation tool for behavioral analysis, built with PySide6 ## Installation -**Prerequisites**: Python 3.8+, pip +Download the **Ethoscore Setup** package for Windows, macOS, or Linux from the +[releases page](https://github.com/BelloneLab/ethoscore/releases). Setup includes +the application and dependencies, lets you choose its installation folder and +desktop / Start menu / Applications shortcuts, and can update an existing install. +Reopen Setup and choose **Download the latest official build**, then +**Update / Reinstall**. Videos, annotations, preferences, and earlier installed +versions are retained. Relaunch Ethoscore to use the new version. + +Native packaging and developer build instructions are in +[installer/INSTALLATION.md](installer/INSTALLATION.md). + +**Run from source**: Python 3.12 and pip are recommended. Use a dedicated environment. **Install dependencies**: ```bash @@ -30,6 +41,98 @@ pip install -r requirements.txt python ethoscore.py ``` +## Multiple arenas in one recording + +1. Open a video and select **Set up arenas** in the right sidebar or **Arenas** menu. +2. Choose **Auto-detect arenas** to propose crops from stable dividers. Review the + proposals. If the layout is irregular or detection finds no reliable dividers, + drag rectangles over the source preview or create a grid with your chosen row + and column counts. Edit names and pixel coordinates in the table. +3. Select **Export arenas**. A background FFmpeg job decodes the source once and + writes all crops. The original recording is never edited. Export can be + cancelled; partial crops are not published. +4. Select an arena using the dropdown or previous / next buttons. **Only that + arena's subvideo is displayed and annotated.** Switching saves the current + arena and restores the other arena's frame position. Annotations, undo history, + and held labeling controls cannot cross into another arena. +5. Track progress with **Unlabeled** (gray), **Ongoing** (amber), and **Completed** + (green). Completion is an explicit decision, including arenas with no events. + Editing a completed arena returns it to Ongoing. + +The source's arena project reopens automatically when you reopen the recording. +You can rename exported arenas using **Manage arena names**. Crop geometry is +fixed after export to protect existing annotations. Original CSVs belonging to +the full video stay separate from arena CSVs. + +Outputs sit next to the source in a dedicated folder: + +```text +recording_arenas/ + arenas.json # Source identity, crop geometry, names, progress + arena_1/ + video.mkv # Only this arena, every original frame in order + video.csv # Independent annotations, Frames starts at 1 + arena.json # Crop and verified encoding metadata + arena_2/ + video.mkv + video.csv + arena.json +``` + +The CSV appears when an arena is first saved or switched away from. Custom arena +names do not change the stable folder identifiers or break saved annotations. +Existing exported videos are never overwritten. A source fingerprint detects +accidental source replacement before a project is reopened. + +Cropping uses FFmpeg's `crop` filter and frame passthrough, with no frame-rate +conversion. Every source and exported crop is decoded to verify exact frame +counts before publication; fractional nominal frame rates and odd crop dimensions +are checked too. Output contains video only. The default H.264 ultrafast encoding +is fast and visually high quality. **Lossless crops** uses lossless YUV encoding; +conversion between RGB and YUV can still change RGB values slightly. Variable-rate +movies retain frame count and order; the existing time-based annotation statistics +use the video's nominal frame rate. + +Automatic detection is a proposal tool for straight, dark arena dividers. It does +not identify animals or guarantee arena boundaries. Manual rectangles and explicit +grids cover layouts without clear divider evidence. Pixel coordinates can be +adjusted before export. See the official [FFmpeg crop documentation](https://ffmpeg.org/ffmpeg-filters.html#crop) +and [frame passthrough options](https://ffmpeg.org/ffmpeg.html). + +## Validation and performance + +Run the integrity and GUI regression checks with: + +```console +python -m unittest discover -s tests -v +python -m unittest installer.test_backend -v +``` + +The GUI tests run offscreen with isolated settings and generated videos. They +cover per-arena CSV isolation, frame restoration, statuses, manual drawing, +failed saves, malformed annotations, unreadable videos, and cached/direct frame +identity. Installer tests cover fresh installs, repeat-run updates, checksums, +shortcut targets, failed updates, and preservation of user files. After building, +`python -m installer.native_smoke` tests the actual packaged setup, repeated +installation, and the installed application's two-arena annotation workflow. + +Reproduce the crop benchmark and visual checks: + +```console +python -m tests.test_arena_backend --benchmark +python tools/plot_export_benchmark.py +python tools/preview_arena_workflow.py +``` + +Benchmark CSVs, PNG/PDF/SVG figures, and UI previews are written under ignored +`tests/artifacts/`. Plotting requires Matplotlib in the environment used for that +optional report. On one Windows workstation, three paired synthetic trials +(180 frames, 640 x 480, 30000/1001 fps, four crops, including exact validation) +gave median times of **2.17 seconds shared decoding** versus **4.66 seconds for +four sequential exports**, a **2.15x** ratio. This describes that workload, not a +universal performance guarantee. Long movies, storage speed, codec, and image +dimensions affect the result. + ## Usage 1. Launch the application diff --git a/annotator_libs/annotation_logic.py b/annotator_libs/annotation_logic.py index a6b0ed1..33c4797 100644 --- a/annotator_libs/annotation_logic.py +++ b/annotator_libs/annotation_logic.py @@ -1,6 +1,7 @@ import os import pandas as pd import numpy as np +import tempfile from PySide6.QtWidgets import QMessageBox, QStatusBar @@ -131,8 +132,13 @@ def sync_video_csv_with_template(df, template_behaviors, csv_path, parent=None): return synced_df -def save_annotations_to_csv(video_path, annotations, behaviors, status_bar=None): - """Save annotations to CSV file using optimized NumPy operations""" +def save_annotations_to_csv(video_path, annotations, behaviors, status_bar=None, total_frames=None): + """Atomically save one CSV row per frame, using a verified count if supplied. + + Arena exports supply a decoded frame count because container headers can be + inaccurate for variable-frame-rate movies. A failed save leaves the prior + CSV intact and raises so the arena switch can be cancelled. + """ if not video_path: if status_bar: status_bar.showMessage("Error: No video loaded. Cannot save annotations.", 3000) @@ -141,9 +147,9 @@ def save_annotations_to_csv(video_path, annotations, behaviors, status_bar=None) return csv_path = os.path.splitext(video_path)[0] + '.csv' - total_frames = get_total_frames_from_video(video_path) - if total_frames == 0: - return + total_frames = get_total_frames_from_video(video_path) if total_frames is None else int(total_frames) + if total_frames <= 0: + raise ValueError("Cannot save annotations without a valid video frame count.") # Use NumPy for fast data preparation num_behaviors = len(behaviors) @@ -154,7 +160,7 @@ def save_annotations_to_csv(video_path, annotations, behaviors, status_bar=None) # Fill array from annotations dict for frame, active_list in annotations.items(): - if frame < total_frames: + if 0 <= frame < total_frames: if isinstance(active_list, list): for b in active_list: if b in behavior_to_idx: @@ -168,12 +174,23 @@ def save_annotations_to_csv(video_path, annotations, behaviors, status_bar=None) data[b] = arr[:, i] df = pd.DataFrame(data) - df.to_csv(csv_path, index=False) + descriptor, temporary = tempfile.mkstemp(prefix='.annotations-', suffix='.tmp', + dir=os.path.dirname(os.path.abspath(csv_path))) + try: + with os.fdopen(descriptor, 'w', encoding='utf-8', newline='') as handle: + df.to_csv(handle, index=False) + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, csv_path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) if status_bar: status_bar.showMessage(f"Annotations saved to {csv_path}", 2000) else: print(f"Annotations saved to {csv_path}") + return csv_path def get_total_frames_from_video(video_path): diff --git a/annotator_libs/arena_export.py b/annotator_libs/arena_export.py new file mode 100644 index 0000000..411307b --- /dev/null +++ b/annotator_libs/arena_export.py @@ -0,0 +1,244 @@ +"""Cancelable, frame-preserving FFmpeg crop export with transactional publishing. + +The source is decoded once for all crops. Validation decodes each completed +output and the source to confirm exact frame counts before any files publish. +These integrity passes are intentionally included in reported export timings. +""" + +from __future__ import annotations + +from fractions import Fraction +from pathlib import Path +import os +import queue +import re +import shutil +import subprocess +import tempfile +import threading + +from annotator_libs.arena_model import ArenaProject, MANIFEST_NAME, atomic_json, source_identity + + +class ExportCancelled(RuntimeError): + """Raised after a cancellation request; staged output has been removed.""" + + +def ffmpeg_executable(): + """Prefer the bundled modern FFmpeg, honoring IMAGEIO_FFMPEG_EXE overrides. + + Some system packages predate ``-fps_mode``. The installer includes a known + modern FFmpeg through imageio-ffmpeg; system lookup is a fallback only. + """ + try: + import imageio_ffmpeg + return imageio_ffmpeg.get_ffmpeg_exe() + except ImportError as error: + executable = shutil.which("ffmpeg") + if executable: + return executable + raise RuntimeError("Install imageio-ffmpeg to enable arena export.") from error + + +def _check_cancel(cancel): + """Keep cancellation semantics identical during processing and validation.""" + if cancel is not None and cancel(): + raise ExportCancelled("Arena export was cancelled. Existing files were preserved.") + + +def _publish_file(staged, destination): + """Atomically publish without replacement on Windows, macOS, and Linux. + + Windows rename refuses replacement and works on NTFS, exFAT, and network + shares. POSIX rename replaces files, so use an atomic hard link there. + """ + if os.name == "nt": + os.rename(staged, destination) + else: + os.link(staged, destination) + + +def _run(arguments, cancel=None, on_frame=None): + """Run FFmpeg without a console window, polling cancellation every 0.1 s. + + Stderr goes to a temporary file to prevent pipe deadlock. A dedicated reader + drains progress stdout so a stalled encoder never blocks cancellation. + """ + _check_cancel(cancel) + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + with tempfile.TemporaryFile(mode="w+b") as errors: + process = subprocess.Popen([ffmpeg_executable(), "-nostdin", "-hide_banner", *map(str, arguments)], + stdin=subprocess.DEVNULL, stdout=subprocess.PIPE, + stderr=errors, creationflags=flags, text=True) + lines = queue.Queue() + + def read_progress(): + """Drain stdout until EOF, retaining only compact progress text.""" + for line in process.stdout: + lines.put(line) + + reader = threading.Thread(target=read_progress, daemon=True) + reader.start() + output = [] + try: + while process.poll() is None or reader.is_alive() or not lines.empty(): + _check_cancel(cancel) + try: + line = lines.get(timeout=0.1) + except queue.Empty: + continue + output.append(line) + if on_frame and line.startswith("frame="): + on_frame(int(line.split("=", 1)[1].strip())) + process.wait() + except BaseException: + process.terminate() + try: + process.wait(timeout=3) + except subprocess.TimeoutExpired: + process.kill() + process.wait() + raise + finally: + reader.join(timeout=3) + process.stdout.close() + errors.seek(0) + diagnostics = errors.read().decode("utf-8", errors="replace") + if process.returncode: + raise RuntimeError(f"FFmpeg failed ({process.returncode}):\n{diagnostics[-6000:]}") + return "".join(output), diagnostics + + +def probe_video(source, exact_count=False, cancel=None): + """Read source dimensions and rational frame rate, optionally decoding count. + + FFmpeg's showinfo reports rational stream frame rate directly, avoiding a + rounded decimal FPS. Exact counts decode the whole video, including VFR. + """ + _, diagnostics = _run(["-i", source, "-map", "0:v:0", "-vf", "showinfo", "-frames:v", "1", + "-an", "-f", "null", "-"], cancel) + rate = re.search(r"frame_rate:\s*(\d+)\s*/\s*(\d+)", diagnostics) + size = re.search(r"\bs:(\d+)x(\d+)", diagnostics) + if not rate or not size or int(rate[2]) == 0: + raise ValueError("Cannot determine video dimensions and rational frame rate.") + fps = Fraction(int(rate[1]), int(rate[2])) + metadata = {"width": int(size[1]), "height": int(size[2]), + "fps_num": fps.numerator, "fps_den": fps.denominator} + if exact_count: + output, _ = _run(["-v", "error", "-i", source, "-map", "0:v:0", "-an", + "-fps_mode", "passthrough", "-progress", "pipe:1", "-nostats", "-f", "null", "-"], cancel) + counts = re.findall(r"^frame=(\d+)\s*$", output, flags=re.MULTILINE) + if not counts or int(counts[-1]) < 1: + raise ValueError("Video has no decodable frames.") + metadata["frame_count"] = int(counts[-1]) + return metadata + + +def export_arenas(source, arenas, project_dir, progress=None, cancel=None, lossless=False): + """Export independent crops while preserving every decoded source frame. + + ``progress`` receives a float from zero to one. ``cancel`` is a callable + returning True when cancellation is requested. Re-running refuses to replace + any existing crop, metadata, or annotation. The caller can reuse completed + exports rather than invoking this function again. + + Matroska plus H.264 4:4:4 supports odd rectangle dimensions. Normal exports + use CRF 18; ``lossless=True`` selects CRF 0. No audio or temporal resampling + is applied. Container timestamps may be quantized to Matroska's time base; + rational frame rate, frame order, and decoded frame count are retained. + """ + source = Path(source).resolve(strict=True) + arenas = list(arenas) + if not arenas: + raise ValueError("Select at least one arena to export.") + project_dir = Path(project_dir).resolve() + project = ArenaProject.create(source, arenas) + project_dir.mkdir(parents=True, exist_ok=True) + original_identity = source_identity(source) + if (project_dir / MANIFEST_NAME).exists(): + existing = ArenaProject.load(project_dir) + if Path(existing.source_path).resolve() != source: + raise ValueError("This project folder belongs to another source video.") + outputs = {arena.id: arena.video_path(project_dir) for arena in arenas} + for arena in arenas: + for path in (outputs[arena.id], arena.folder(project_dir) / "arena.json"): + if path.exists(): + raise FileExistsError(f"Existing arena output will not be overwritten: {path}") + source_info = probe_video(source, cancel=cancel) + _check_cancel(cancel) + stage = Path(tempfile.mkdtemp(prefix=".arena-export-", dir=project_dir)).resolve() + published = [] + made_folders = [] + report = progress or (lambda value: None) + try: + report(0.0) + if len(arenas) == 1: + filters = "[0:v:0]" + f"crop={arenas[0].width}:{arenas[0].height}:{arenas[0].x}:{arenas[0].y}:exact=1[c0]" + else: + split = "[0:v:0]split=" + str(len(arenas)) + "".join(f"[s{i}]" for i in range(len(arenas))) + crops = [f"[s{i}]crop={a.width}:{a.height}:{a.x}:{a.y}:exact=1[c{i}]" for i, a in enumerate(arenas)] + filters = ";".join([split, *crops]) + arguments = ["-v", "error", "-i", source, "-filter_complex_threads", "1", "-filter_complex", filters, + "-progress", "pipe:1", "-nostats"] + estimated_count = max(1, project.source_metadata.get("frame_count", 1)) + for index, arena in enumerate(arenas): + # No -r, -t, or fps filter: every crop receives every source frame. + arguments += ["-map", f"[c{index}]", "-an", "-c:v", "libx264", "-preset", "ultrafast", + "-crf", "0" if lossless else "18", "-pix_fmt", "yuv444p", "-threads", "2", + "-fps_mode", "passthrough", "-n", stage / f"{arena.id}.mkv"] + _run(arguments, cancel, lambda frame: report(min(0.7, 0.7 * frame / estimated_count))) + # Header counts can lie for damaged and variable-frame-rate sources. + # Decode both sides before publishing and reject truncated outputs. + source_info = probe_video(source, exact_count=True, cancel=cancel) + report(0.75) + for index, arena in enumerate(arenas): + measured = probe_video(stage / f"{arena.id}.mkv", exact_count=True, cancel=cancel) + if measured["frame_count"] != source_info["frame_count"]: + raise RuntimeError(f"Frame count mismatch for {arena.label}; no crops were published.") + if (measured["width"], measured["height"]) != (arena.width, arena.height): + raise RuntimeError(f"Crop dimensions changed for {arena.label}.") + if Fraction(measured["fps_num"], measured["fps_den"]) != Fraction(source_info["fps_num"], source_info["fps_den"]): + raise RuntimeError(f"Frame rate changed for {arena.label}.") + report(0.75 + 0.23 * (index + 1) / len(arenas)) + if source_identity(source) != original_identity: + raise RuntimeError("Source video changed during export.") + _check_cancel(cancel) + for arena in arenas: + folder = arena.folder(project_dir) + if not folder.exists(): + folder.mkdir() + made_folders.append(folder) + destination = outputs[arena.id] + # Staging and destination reside on the same filesystem. Publishing + # is atomic and cannot replace a concurrent export's files. + _publish_file(stage / f"{arena.id}.mkv", destination) + published.append(destination) + metadata_path = folder / "arena.json" + staged_metadata = stage / f"{arena.id}.json" + from dataclasses import asdict + atomic_json(staged_metadata, {"arena": asdict(arena), "source": str(source), + "source_identity": original_identity, "video": source_info, + "codec": "libx264", "lossless": bool(lossless)}) + _publish_file(staged_metadata, metadata_path) + published.append(metadata_path) + project.source_metadata.update(source_info) + if not (project_dir / MANIFEST_NAME).exists(): + project.save(project_dir) + published.append(project_dir / MANIFEST_NAME) + report(1.0) + return outputs + except BaseException: + # Only remove the exact paths created by this transaction. Annotation + # CSVs, prior exports, and existing project metadata are never deleted. + for path in reversed(published): + path.unlink(missing_ok=True) + for folder in reversed(made_folders): + try: + folder.rmdir() + except OSError: + pass + raise + finally: + # stage was created by mkdtemp directly inside the validated project. + if stage.parent == project_dir and stage.name.startswith(".arena-export-"): + shutil.rmtree(stage) diff --git a/annotator_libs/arena_model.py b/annotator_libs/arena_model.py new file mode 100644 index 0000000..4f518aa --- /dev/null +++ b/annotator_libs/arena_model.py @@ -0,0 +1,277 @@ +"""Validated arena geometry, source identity, detection, and project persistence. + +Coordinates always refer to the full resolution source image. Rectangles use +integer half-open pixel bounds: ``frame[y:y + height, x:x + width]``. +""" + +from __future__ import annotations + +from dataclasses import asdict, dataclass, field +from pathlib import Path +import hashlib +import json +import os +import re +import tempfile + +import cv2 +import numpy as np + + +STATUSES = ("unlabeled", "ongoing", "completed") +STATUS_COLORS = {"unlabeled": "#778294", "ongoing": "#f5b942", "completed": "#36c993"} +MANIFEST_NAME = "arenas.json" + + +@dataclass +class Arena: + """A named crop and its independent annotation progress.""" + + id: str + label: str + x: int + y: int + width: int + height: int + status: str = "unlabeled" + last_frame: int = 0 + + def __post_init__(self): + """Reject unsafe folder names and malformed source coordinates.""" + if not isinstance(self.id, str) or not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9_-]{0,79}", self.id): + raise ValueError("Arena ID must be a safe alphanumeric folder name.") + if self.id.upper() in {"CON", "PRN", "AUX", "NUL", *(f"COM{i}" for i in range(1, 10)), *(f"LPT{i}" for i in range(1, 10))}: + raise ValueError("Arena ID is reserved by Windows.") + if not isinstance(self.label, str) or not self.label.strip(): + raise ValueError("Every arena needs a nonempty label.") + if any(type(v) is not int for v in (self.x, self.y, self.width, self.height, self.last_frame)): + raise ValueError("Arena coordinates and last frame must be integers.") + if min(self.x, self.y, self.last_frame) < 0 or min(self.width, self.height) < 2: + raise ValueError("Arena bounds must be positive and at least 2 pixels wide and tall.") + if self.status not in STATUSES: + raise ValueError(f"Unknown arena status: {self.status}") + + @property + def rect(self): + """Return the source-space rectangle as x, y, width, height.""" + return self.x, self.y, self.width, self.height + + def validate_bounds(self, width, height): + """Reject rectangles extending beyond the original video.""" + self.__post_init__() + if self.x + self.width > width or self.y + self.height > height: + raise ValueError(f"Arena {self.label!r} extends outside the source video.") + + def folder(self, project_dir): + """Resolve the arena folder, rejecting symlink escapes.""" + self.__post_init__() + base = Path(project_dir).resolve() + folder = (base / self.id).resolve() + if folder.parent != base: + raise ValueError("Arena folder escapes its project directory.") + return folder + + def video_path(self, project_dir): + """Return the crop path used by the player.""" + return self.folder(project_dir) / "video.mkv" + + def annotations_path(self, project_dir): + """Match the application's existing same-stem CSV convention.""" + return self.folder(project_dir) / "video.csv" + + +def default_project_dir(source): + """Keep a video's arena project in a dedicated neighboring folder.""" + source = Path(source).resolve() + return source.parent / f"{source.stem}_arenas" + + +def source_identity(source): + """Fingerprint the source without modifying it or scanning entire movies. + + Size, modification time, and a hash of the first and last 1 MiB detect the + common accidental replacement cases. This is not a whole-file content hash. + """ + source = Path(source).resolve(strict=True) + stat = source.stat() + digest = hashlib.sha256() + with source.open("rb") as handle: + digest.update(handle.read(1024 * 1024)) + if stat.st_size > 1024 * 1024: + handle.seek(max(0, stat.st_size - 1024 * 1024)) + digest.update(handle.read(1024 * 1024)) + return {"path": str(source), "size_bytes": stat.st_size, "mtime_ns": stat.st_mtime_ns, + "sample_sha256": digest.hexdigest()} + + +def atomic_json(path, data): + """Write complete JSON before replacing a manifest in the same directory.""" + path = Path(path) + path.parent.mkdir(parents=True, exist_ok=True) + descriptor, temporary = tempfile.mkstemp(prefix=".arena-", suffix=".tmp", dir=path.parent) + try: + with os.fdopen(descriptor, "w", encoding="utf-8") as handle: + json.dump(data, handle, indent=2, ensure_ascii=False) + handle.write("\n") + handle.flush() + os.fsync(handle.fileno()) + os.replace(temporary, path) + finally: + if os.path.exists(temporary): + os.unlink(temporary) + + +@dataclass +class ArenaProject: + """Versioned source identity and ordered arena metadata.""" + + source_path: str + arenas: list[Arena] + source_metadata: dict = field(default_factory=dict) + schema_version: int = 1 + + @classmethod + def create(cls, source, arenas): + """Capture original source identity and validate geometry immediately.""" + source = str(Path(source).resolve(strict=True)) + capture = cv2.VideoCapture(source) + try: + if not capture.isOpened(): + raise ValueError("Cannot open the source video.") + metadata = source_identity(source) + metadata.update(width=int(capture.get(cv2.CAP_PROP_FRAME_WIDTH)), + height=int(capture.get(cv2.CAP_PROP_FRAME_HEIGHT)), + frame_count=int(capture.get(cv2.CAP_PROP_FRAME_COUNT)), + fps=float(capture.get(cv2.CAP_PROP_FPS))) + finally: + capture.release() + project = cls(source, list(arenas), metadata) + project.validate() + return project + + def validate(self): + """Reject duplicate identifiers, malformed metadata, and invalid bounds.""" + if self.schema_version != 1: + raise ValueError("Unsupported arena project version.") + if len({arena.id.casefold() for arena in self.arenas}) != len(self.arenas): + raise ValueError("Arena IDs must be unique, including letter case.") + width, height = self.source_metadata.get("width"), self.source_metadata.get("height") + if not isinstance(width, int) or not isinstance(height, int) or min(width, height) < 2: + raise ValueError("Source dimensions are missing or invalid.") + for arena in self.arenas: + arena.validate_bounds(width, height) + + def save(self, project_dir=None): + """Atomically persist metadata; never touch videos or annotation CSVs.""" + self.validate() + project_dir = Path(project_dir or default_project_dir(self.source_path)).resolve() + if project_dir == Path(self.source_path).resolve(): + raise ValueError("The project folder cannot replace the source video.") + for arena in self.arenas: + arena.folder(project_dir) + atomic_json(project_dir / MANIFEST_NAME, asdict(self)) + return project_dir + + @classmethod + def load(cls, project_dir, verify_source=True): + """Load a project and detect source replacements before annotations mix.""" + project_dir = Path(project_dir).resolve() + with (project_dir / MANIFEST_NAME).open(encoding="utf-8") as handle: + data = json.load(handle) + data["arenas"] = [Arena(**item) for item in data["arenas"]] + project = cls(**data) + project.validate() + for arena in project.arenas: + arena.folder(project_dir) + if verify_source: + identity = source_identity(project.source_path) + if any(identity[key] != project.source_metadata.get(key) for key in ("size_bytes", "mtime_ns", "sample_sha256")): + raise ValueError("The source video changed since this project was created.") + return project + + +def sample_video_frame(source, samples=7): + """Return a temporal-median BGR image to suppress moving animals. + + Run in a background worker for long or remote movies. The source is opened + read-only; at most seven sample images are retained by default. + """ + capture = cv2.VideoCapture(str(source)) + try: + if not capture.isOpened(): + raise ValueError("Cannot open source video.") + total = max(1, int(capture.get(cv2.CAP_PROP_FRAME_COUNT))) + frames = [] + for index in np.linspace(0, total - 1, max(1, int(samples)), dtype=int): + capture.set(cv2.CAP_PROP_POS_FRAMES, int(index)) + ok, frame = capture.read() + if ok: + frames.append(frame) + if not frames: + raise ValueError("No readable video frames.") + return np.median(np.stack(frames), axis=0).astype(np.uint8) + finally: + capture.release() + + +def grid_arenas(width, height, rows=2, columns=2): + """Provide an explicit, user-requested grid fallback without lost pixels.""" + if min(rows, columns) < 1 or width // columns < 2 or height // rows < 2: + raise ValueError("Grid cells must be at least 2 pixels wide and tall.") + xs = np.linspace(0, width, columns + 1, dtype=int) + ys = np.linspace(0, height, rows + 1, dtype=int) + return [Arena(f"arena_{r * columns + c + 1}", f"Arena {r * columns + c + 1}", + int(xs[c]), int(ys[r]), int(xs[c + 1] - xs[c]), int(ys[r + 1] - ys[r])) + for r in range(rows) for c in range(columns)] + + +def _divider_positions(gray, vertical): + """Find long narrow dark ridges, avoiding wide shadows and edge walls.""" + image = gray if vertical else gray.T + height, width = image.shape + if width < 32 or height < 32: + return [] + # Compare each line against nearby background; a divider must remain dark + # for most of its length, unlike an animal or a local marking. + radius = max(5, int(width * 0.018)) + blurred = cv2.GaussianBlur(image, (0, 0), 0.8) + left = np.roll(blurred, radius, axis=1) + right = np.roll(blurred, -radius, axis=1) + # Require brighter background on both sides. A single illumination step at + # an outer wall can otherwise masquerade as an additional interior divider. + contrast = np.minimum(left, right) - blurred + usable = contrast[int(height * 0.08):int(height * 0.92)] + strength = np.mean(usable > 8, axis=0) + candidates = np.flatnonzero(strength > 0.66) + candidates = candidates[(candidates > width * 0.14) & (candidates < width * 0.86)] + if not len(candidates): + return [] + groups = np.split(candidates, np.flatnonzero(np.diff(candidates) > max(2, radius // 2)) + 1) + return [int(group[np.argmax(strength[group])]) for group in groups + if len(group) < width * 0.045][:7] + + +def detect_arenas(frame): + """Suggest rectangles only when sustained divider evidence is present. + + Automatic proposals remain editable. Blank images and animal-sized contours + deliberately return an empty list, allowing the UI to offer a manual grid. + """ + if frame is None or frame.ndim not in (2, 3): + raise ValueError("Expected a grayscale or BGR image.") + gray = cv2.cvtColor(frame, cv2.COLOR_BGR2GRAY) if frame.ndim == 3 else frame + gray = gray.astype(np.float32) + height, width = gray.shape + xs, ys = _divider_positions(gray, True), _divider_positions(gray, False) + if not xs and not ys: + return [] + xs, ys = [0, *xs, width], [0, *ys, height] + # Reject close proposals: they usually describe two edges of one divider. + if min(np.diff(xs)) < width * 0.12 or min(np.diff(ys)) < height * 0.12: + return [] + arenas = [] + for top, bottom in zip(ys, ys[1:]): + for left, right in zip(xs, xs[1:]): + index = len(arenas) + 1 + arenas.append(Arena(f"arena_{index}", f"Arena {index}", left, top, right - left, bottom - top)) + return arenas diff --git a/annotator_libs/arena_ui.py b/annotator_libs/arena_ui.py new file mode 100644 index 0000000..8317052 --- /dev/null +++ b/annotator_libs/arena_ui.py @@ -0,0 +1,392 @@ +"""Arena drawing, review, and export widgets used by the annotation window. + +Rectangles always use source-video pixels. Display scaling only affects the +preview, so drawing on a small screen never changes the exported coordinates. +""" + +from copy import deepcopy +from pathlib import Path +from uuid import uuid4 + +import cv2 +from PySide6.QtCore import Qt, QRectF, QThread, Signal +from PySide6.QtGui import QColor, QImage, QPainter, QPen, QPixmap +from PySide6.QtWidgets import ( + QAbstractItemView, QCheckBox, QDialog, QDialogButtonBox, QGraphicsScene, + QGraphicsView, QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton, + QSpinBox, QSplitter, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, +) + +from annotator_libs.arena_model import Arena, detect_arenas, grid_arenas, sample_video_frame +from annotator_libs.arena_export import export_arenas + + +STATUS_COLORS = {"unlabeled": "#8b98aa", "ongoing": "#f5b94c", "completed": "#42d6a4"} +STATUS_NAMES = {"unlabeled": "Unlabeled", "ongoing": "Ongoing", "completed": "Completed"} +ARENA_COLORS = ("#54c7ec", "#b5a1ff", "#ffc36b", "#64e2bb", "#fa93be", "#a8d36f") + +ARENA_STYLE = """ +QDialog { background: #151c27; color: #e7edf6; } +QLabel { color: #c7d2e3; background: transparent; } +QLabel#title { color: #f4f7fc; font-size: 24px; font-weight: 700; } +QLabel#muted { color: #95a5bc; } +QPushButton { background: #273449; color: #edf3fc; border: 1px solid #3a4a61; + border-radius: 6px; padding: 9px 13px; } +QPushButton:hover { background: #354962; } +QPushButton:disabled { color: #66778f; background: #202a39; } +QPushButton#primary { background: #167d72; border: none; font-weight: 700; } +QPushButton#primary:hover { background: #229c8d; } +QTableWidget { background: #1b2534; color: #edf3fc; gridline-color: #2f4056; + border: 1px solid #33445b; selection-background-color: #315373; } +QHeaderView::section { background: #253347; color: #b6c6da; padding: 7px; border: none; } +QSpinBox { background: #26354a; color: #edf3fc; padding: 5px; border: 1px solid #3b506a; } +QCheckBox { color: #c7d2e3; padding: 6px; } +""" + + +class ArenaPreview(QGraphicsView): + """Draw rectangles directly over a source-resolution video preview.""" + + rectangle_drawn = Signal(int, int, int, int) + + def __init__(self, frame, parent=None): + """Own a detached RGB preview and prepare a scene in video pixels.""" + super().__init__(parent) + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + height, width = rgb.shape[:2] + self.image_width, self.image_height = width, height + image = QImage(rgb.data, width, height, int(rgb.strides[0]), QImage.Format_RGB888).copy() + self.preview_scene = QGraphicsScene(self) + self.setScene(self.preview_scene) + self.preview_scene.addPixmap(QPixmap.fromImage(image)) + self.setSceneRect(0, 0, width, height) + self.setBackgroundBrush(QColor("#0d121b")) + self.setRenderHint(QPainter.Antialiasing) + self.setMinimumSize(420, 320) + self.setCursor(Qt.CrossCursor) + self.draw_enabled = True + self._start = None + self._rubber = None + self._overlays = [] + + def resizeEvent(self, event): + """Fit the complete source image without stretching its geometry.""" + super().resizeEvent(event) + self.fitInView(self.sceneRect(), Qt.KeepAspectRatio) + + def show_arenas(self, arenas, selected=-1): + """Redraw named arena overlays, highlighting the selected table row.""" + for item in self._overlays: + self.preview_scene.removeItem(item) + self._overlays.clear() + for index, arena in enumerate(arenas): + color = QColor(ARENA_COLORS[index % len(ARENA_COLORS)]) + pen = QPen(color, 4 if index == selected else 2) + pen.setCosmetic(True) + rect = self.preview_scene.addRect(QRectF(arena.x, arena.y, arena.width, arena.height), pen) + fill = QColor(color) + fill.setAlpha(35 if index == selected else 15) + rect.setBrush(fill) + text = self.preview_scene.addText(arena.label) + text.setDefaultTextColor(color) + font = text.font() + font.setPixelSize(max(13, round(self.image_width / 55))) + font.setBold(True) + text.setFont(font) + text.setPos(arena.x + 5, arena.y + 3) + self._overlays.extend((rect, text)) + + def _point(self, event): + """Clamp a pointer position to valid source-image boundaries.""" + point = self.mapToScene(event.position().toPoint()) + point.setX(max(0, min(self.image_width, point.x()))) + point.setY(max(0, min(self.image_height, point.y()))) + return point + + def mousePressEvent(self, event): + """Begin a rectangular arena with a left-button drag.""" + if self.draw_enabled and event.button() == Qt.LeftButton: + self._start = self._point(event) + pen = QPen(QColor("#ffffff"), 2, Qt.DashLine) + pen.setCosmetic(True) + self._rubber = self.preview_scene.addRect(QRectF(self._start, self._start), pen) + event.accept() + return + super().mousePressEvent(event) + + def mouseMoveEvent(self, event): + """Preview the rectangle while keeping coordinates in source pixels.""" + if self._rubber is not None: + self._rubber.setRect(QRectF(self._start, self._point(event)).normalized()) + event.accept() + return + super().mouseMoveEvent(event) + + def mouseReleaseEvent(self, event): + """Emit a nondegenerate crop, or discard an accidental short click.""" + if self._rubber is not None and event.button() == Qt.LeftButton: + rect = QRectF(self._start, self._point(event)).normalized().toAlignedRect() + self.preview_scene.removeItem(self._rubber) + self._start = self._rubber = None + if rect.width() >= 8 and rect.height() >= 8: + self.rectangle_drawn.emit(rect.x(), rect.y(), + min(rect.width(), self.image_width - rect.x()), + min(rect.height(), self.image_height - rect.y())) + event.accept() + return + super().mouseReleaseEvent(event) + + +class DetectionWorker(QThread): + """Sample and detect in a worker so long-GOP source videos do not freeze Qt.""" + + detected = Signal(object, object) + failed = Signal(str) + + def __init__(self, source, parent=None): + """Store the source path; no video handles cross thread boundaries.""" + super().__init__(parent) + self.source = str(source) + + def run(self): + """Return a median preview with detected proposals, or a useful error.""" + try: + frame = sample_video_frame(self.source) + self.detected.emit(frame, detect_arenas(frame)) + except Exception as error: + self.failed.emit(str(error)) + + +class ArenaEditor(QDialog): + """Review automatic proposals, draw manual crops, and assign arena names.""" + + def __init__(self, source, frame, arenas=None, geometry_locked=False, parent=None): + """Initialize a reversible draft. Existing exported geometry is locked.""" + super().__init__(parent) + self.source = str(source) + self.arenas = deepcopy(arenas or []) + self.geometry_locked = geometry_locked + self.detection_worker = None + self.setWindowTitle("Ethoscore | Set up arenas") + self.resize(1140, 740) + self.setMinimumSize(850, 600) + self.setStyleSheet(ARENA_STYLE) + layout = QVBoxLayout(self) + title = QLabel("One video. Individual arenas.") + title.setObjectName("title") + layout.addWidget(title) + self.hint = QLabel("Detect dividers automatically, or drag rectangles over the preview. Review every crop before exporting.") + self.hint.setWordWrap(True) + self.hint.setObjectName("muted") + layout.addWidget(self.hint) + toolbar = QHBoxLayout() + self.detect_button = QPushButton("Auto-detect arenas") + self.detect_button.clicked.connect(self.auto_detect) + self.detect_button.setEnabled(not geometry_locked) + toolbar.addWidget(self.detect_button) + toolbar.addStretch() + toolbar.addWidget(QLabel("Grid")) + self.rows = QSpinBox() + self.columns = QSpinBox() + for spin in (self.rows, self.columns): + spin.setRange(1, 8) + spin.setValue(2) + spin.setEnabled(not geometry_locked) + toolbar.addWidget(spin) + self.grid_button = QPushButton("Create grid") + self.grid_button.clicked.connect(self.create_grid) + self.grid_button.setEnabled(not geometry_locked) + toolbar.addWidget(self.grid_button) + layout.addLayout(toolbar) + splitter = QSplitter() + self.preview = ArenaPreview(frame) + self.preview.draw_enabled = not geometry_locked + self.preview.rectangle_drawn.connect(self.add_rectangle) + splitter.addWidget(self.preview) + sidebar = QWidget() + side = QVBoxLayout(sidebar) + side.setContentsMargins(8, 0, 0, 0) + side.addWidget(QLabel("ARENAS / SOURCE PIXELS")) + self.table = QTableWidget(0, 5) + self.table.setHorizontalHeaderLabels(["Name", "X", "Y", "Width", "Height"]) + self.table.verticalHeader().setVisible(False) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + self.table.setSelectionMode(QAbstractItemView.SingleSelection) + self.table.horizontalHeader().setSectionResizeMode(0, QHeaderView.Stretch) + for column in range(1, 5): + self.table.horizontalHeader().setSectionResizeMode(column, QHeaderView.ResizeToContents) + self.table.itemChanged.connect(self.update_draft) + self.table.itemSelectionChanged.connect(self.refresh_preview) + side.addWidget(self.table) + self.remove_button = QPushButton("Remove selected arena") + self.remove_button.clicked.connect(self.remove_selected) + self.remove_button.setEnabled(not geometry_locked) + side.addWidget(self.remove_button) + self.summary = QLabel() + self.summary.setWordWrap(True) + self.summary.setObjectName("muted") + side.addWidget(self.summary) + splitter.addWidget(sidebar) + splitter.setSizes([720, 400]) + layout.addWidget(splitter, 1) + self.lossless_box = QCheckBox("Lossless crops (larger files)") + self.lossless_box.setToolTip("Both modes preserve frame count and frame order. Lossless uses lossless YUV encoding; RGB conversion may slightly change pixel values.") + self.lossless_box.setEnabled(not geometry_locked) + layout.addWidget(self.lossless_box) + self.validation = QLabel() + self.validation.setWordWrap(True) + layout.addWidget(self.validation) + footer = QHBoxLayout() + footer.addWidget(QLabel("Original video stays untouched. Each arena gets its own video and annotations."), 1) + buttons = QDialogButtonBox(QDialogButtonBox.Cancel) + self.apply_button = buttons.addButton("Save names" if geometry_locked else "Export arenas", QDialogButtonBox.AcceptRole) + self.apply_button.setObjectName("primary") + buttons.accepted.connect(self.accept) + buttons.rejected.connect(self.reject) + footer.addWidget(buttons) + layout.addLayout(footer) + if geometry_locked: + self.hint.setText("Rename the existing arenas here. Exported crop geometry is fixed to protect existing annotations.") + self.populate_table() + + @property + def lossless(self): + """Expose the user's encoding choice without coupling the worker to Qt.""" + return self.lossless_box.isChecked() + + def populate_table(self): + """Rebuild the coordinate table without triggering intermediate edits.""" + self.table.blockSignals(True) + self.table.setRowCount(len(self.arenas)) + for row, arena in enumerate(self.arenas): + for column, value in enumerate((arena.label, arena.x, arena.y, arena.width, arena.height)): + item = QTableWidgetItem(str(value)) + if self.geometry_locked and column: + item.setFlags(item.flags() & ~Qt.ItemIsEditable) + self.table.setItem(row, column, item) + self.table.blockSignals(False) + self.refresh_preview() + self.validate_draft() + + def refresh_preview(self): + """Keep the preview and selected coordinate row in agreement.""" + self.preview.show_arenas(self.arenas, self.table.currentRow()) + self.summary.setText(f"{len(self.arenas)} arenas. Coordinates are measured from the top-left of the original video. Overlapping crops are allowed.") + + def validate_draft(self): + """Prevent empty names, duplicate names, and out-of-frame rectangles.""" + message = "" + labels = [arena.label.strip().casefold() for arena in self.arenas] + if not self.arenas: + message = "Draw an arena, create a grid, or run auto-detection to continue." + elif any(not label for label in labels) or len(set(labels)) != len(labels): + message = "Give every arena a unique, nonempty name." + elif any(arena.x < 0 or arena.y < 0 or arena.width < 8 or arena.height < 8 + or arena.x + arena.width > self.preview.image_width + or arena.y + arena.height > self.preview.image_height for arena in self.arenas): + message = "Every crop must stay inside the source and be at least 8 by 8 pixels." + self.validation.setText(message) + self.apply_button.setEnabled(not message) + return not message + + def update_draft(self, item): + """Apply table edits to the draft and reject invalid numeric entries.""" + arena = self.arenas[item.row()] + if item.column() == 0: + arena.label = item.text().strip() + else: + try: + value = int(item.text()) + except ValueError: + self.populate_table() + return + setattr(arena, ("label", "x", "y", "width", "height")[item.column()], value) + self.refresh_preview() + self.validate_draft() + + def add_rectangle(self, x, y, width, height): + """Append a stable-identity arena, using a friendly default label.""" + if self.geometry_locked: + return + number = len(self.arenas) + 1 + existing = {arena.label for arena in self.arenas} + while f"Arena {number}" in existing: + number += 1 + self.arenas.append(Arena(f"arena_{uuid4().hex[:12]}", f"Arena {number}", x, y, width, height)) + self.populate_table() + self.table.selectRow(len(self.arenas) - 1) + + def remove_selected(self): + """Remove a draft rectangle without touching any on-disk media.""" + row = self.table.currentRow() + if row >= 0 and not self.geometry_locked: + self.arenas.pop(row) + self.populate_table() + + def create_grid(self): + """Offer an explicit equal-sized grid when automatic detection is unsuitable.""" + self.arenas = grid_arenas(self.preview.image_width, self.preview.image_height, + self.rows.value(), self.columns.value()) + self.populate_table() + + def auto_detect(self): + """Compute automatic proposals in the background; the user reviews them.""" + self.detect_button.setEnabled(False) + self.grid_button.setEnabled(False) + self.apply_button.setEnabled(False) + self.hint.setText("Sampling the video and looking for stable arena dividers...") + self.detection_worker = DetectionWorker(self.source, self) + self.detection_worker.detected.connect(self.detection_finished) + self.detection_worker.failed.connect(self.detection_failed) + self.detection_worker.start() + + def detection_finished(self, frame, arenas): + """Present detected rectangles without treating uncertain proposals as truth.""" + self.detect_button.setEnabled(True) + self.grid_button.setEnabled(True) + if arenas: + self.arenas = arenas + self.hint.setText(f"Found {len(arenas)} arena proposals. Review boundaries and names, then export.") + else: + self.hint.setText("No reliable dividers found. Draw arenas manually or create a grid.") + self.populate_table() + + def detection_failed(self, message): + """Restore editing after a sampling failure and surface its cause.""" + self.detect_button.setEnabled(True) + self.grid_button.setEnabled(True) + self.hint.setText(f"Detection could not finish: {message}") + self.validate_draft() + + def done(self, result): + """Keep the dialog alive while its detection worker owns video resources.""" + if self.detection_worker and self.detection_worker.isRunning(): + self.hint.setText("Please wait for detection to finish before closing this dialog.") + return + if result == QDialog.Accepted and not self.validate_draft(): + return + super().done(result) + + +class ExportWorker(QThread): + """Export and validate crops without blocking the annotation interface.""" + + progress = Signal(int) + exported = Signal(object) + failed = Signal(str) + + def __init__(self, source, arenas, project_dir, lossless=False, parent=None): + """Copy the reviewed arena definitions before passing them to FFmpeg.""" + super().__init__(parent) + self.source, self.arenas = str(source), deepcopy(arenas) + self.project_dir, self.lossless = Path(project_dir), lossless + + def run(self): + """Forward progress, cooperative cancellation, and validated output paths.""" + try: + paths = export_arenas(self.source, self.arenas, self.project_dir, + progress=lambda value: self.progress.emit(round(value * 100)), + cancel=self.isInterruptionRequested, lossless=self.lossless) + self.exported.emit(paths) + except Exception as error: + self.failed.emit(str(error)) diff --git a/annotator_libs/arena_workflow.py b/annotator_libs/arena_workflow.py new file mode 100644 index 0000000..962d863 --- /dev/null +++ b/annotator_libs/arena_workflow.py @@ -0,0 +1,404 @@ +"""Per-video arena projects integrated into the existing annotation window. + +The selected crop is a real video file. The established annotation editor, +keyboard controls, timeline, and CSV format therefore operate on exactly one +arena at a time, with unchanged source frame numbering. +""" + +from pathlib import Path + +import cv2 +import numpy as np +import pandas as pd +from PySide6.QtCore import Qt, QUrl +from PySide6.QtGui import QColor, QDesktopServices, QIcon, QPixmap +from PySide6.QtWidgets import ( + QComboBox, QDialog, QGroupBox, QHBoxLayout, QLabel, QMessageBox, + QProgressDialog, QPushButton, QVBoxLayout, +) + +from annotator_libs.annotation_logic import apply_range_label +from annotator_libs.arena_model import ArenaProject, default_project_dir +from annotator_libs.arena_ui import ArenaEditor, ExportWorker, STATUS_COLORS, STATUS_NAMES + + +class ArenaWorkflow: + """Add arena management without duplicating the existing video editor.""" + + def init_arena_workflow(self): + """Initialize state before any menu, video, or settings callbacks run.""" + self.arena_project = None + self.arena_project_dir = None + self.active_arena_id = None + self.arena_export_worker = None + self.arena_progress_dialog = None + self._loading_arena = False + + def create_arena_panel(self): + """Build a compact arena selector with accessible status text and colors.""" + group = QGroupBox("ARENAS") + group.setStyleSheet(""" + QGroupBox { color: #9fb0c5; font-weight: bold; border: 1px solid #3d4a5a; + border-radius: 8px; margin-top: 10px; padding-top: 14px; } + QGroupBox::title { subcontrol-origin: margin; left: 12px; padding: 0 5px; } + QPushButton { color: #eaf2fa; background: #2b3a4b; border: none; + border-radius: 5px; padding: 7px; } + QPushButton:hover { background: #3a5067; } + QPushButton:disabled { color: #657487; background: #252d37; } + QComboBox { color: #eaf2fa; background: #273544; border: 1px solid #45556a; + border-radius: 5px; padding: 6px; min-height: 20px; } + QComboBox QAbstractItemView { color: #eaf2fa; background: #273544; + selection-background-color: #326473; } + """) + layout = QVBoxLayout(group) + self.arena_setup_button = QPushButton("Set up arenas...") + self.arena_setup_button.clicked.connect(self.configure_arenas) + layout.addWidget(self.arena_setup_button) + row = QHBoxLayout() + self.arena_previous_button = QPushButton("<") + self.arena_previous_button.setMaximumWidth(34) + self.arena_previous_button.setToolTip("Previous arena") + self.arena_previous_button.clicked.connect(lambda: self.step_arena(-1)) + row.addWidget(self.arena_previous_button) + self.arena_selector = QComboBox() + self.arena_selector.setMinimumContentsLength(12) + self.arena_selector.setSizeAdjustPolicy(QComboBox.AdjustToMinimumContentsLengthWithIcon) + self.arena_selector.currentIndexChanged.connect(self.select_arena_from_combo) + row.addWidget(self.arena_selector, 1) + self.arena_next_button = QPushButton(">") + self.arena_next_button.setMaximumWidth(34) + self.arena_next_button.setToolTip("Next arena") + self.arena_next_button.clicked.connect(lambda: self.step_arena(1)) + row.addWidget(self.arena_next_button) + layout.addLayout(row) + status_row = QHBoxLayout() + status_row.addWidget(QLabel("Status")) + self.arena_status_selector = QComboBox() + for key, label in STATUS_NAMES.items(): + self.arena_status_selector.addItem(self.status_icon(key), label, key) + self.arena_status_selector.currentIndexChanged.connect(self.change_arena_status) + status_row.addWidget(self.arena_status_selector, 1) + layout.addLayout(status_row) + self.arena_hint = QLabel("Split a video into independent annotation sessions.") + self.arena_hint.setWordWrap(True) + self.arena_hint.setStyleSheet("color: #93a2b5; font-size: 11px; font-weight: normal;") + layout.addWidget(self.arena_hint) + self.arena_folder_button = QPushButton("Open arena folder") + self.arena_folder_button.clicked.connect(self.open_arena_folder) + layout.addWidget(self.arena_folder_button) + self.refresh_arena_panel() + return group + + @staticmethod + def status_icon(status): + """Make a color swatch while retaining readable status text beside it.""" + pixmap = QPixmap(12, 12) + pixmap.fill(QColor(STATUS_COLORS.get(status, STATUS_COLORS["unlabeled"]))) + return QIcon(pixmap) + + def active_arena(self): + """Return the active arena definition, or None for a normal full video.""" + if self.arena_project: + return next((arena for arena in self.arena_project.arenas if arena.id == self.active_arena_id), None) + return None + + def refresh_arena_panel(self): + """Synchronize selector names, icons, status, and enabled controls.""" + if not hasattr(self, "arena_selector"): + return + self.arena_selector.blockSignals(True) + self.arena_selector.clear() + arenas = self.arena_project.arenas if self.arena_project else [] + for arena in arenas: + self.arena_selector.addItem(self.status_icon(arena.status), + f"{arena.label} | {STATUS_NAMES[arena.status]}", arena.id) + selected = self.arena_selector.findData(self.active_arena_id) + if selected >= 0: + self.arena_selector.setCurrentIndex(selected) + self.arena_selector.blockSignals(False) + active = self.active_arena() + self.arena_selector.setEnabled(bool(arenas)) + self.arena_previous_button.setEnabled(len(arenas) > 1) + self.arena_next_button.setEnabled(len(arenas) > 1) + self.arena_status_selector.setEnabled(active is not None) + self.arena_status_selector.blockSignals(True) + self.arena_status_selector.setCurrentIndex(self.arena_status_selector.findData(active.status if active else "unlabeled")) + self.arena_status_selector.blockSignals(False) + self.arena_folder_button.setEnabled(self.arena_project_dir is not None) + self.arena_setup_button.setEnabled(bool(getattr(self, "video_path", ""))) + self.arena_setup_button.setText("Manage arena names..." if arenas else "Set up arenas...") + if active: + completed = sum(arena.status == "completed" for arena in arenas) + self.arena_hint.setText(f"{completed}/{len(arenas)} completed. Only {active.label} is displayed and annotated.") + else: + self.arena_hint.setText("Split a video into independent annotation sessions.") + + def _settle_arena_input(self): + """Finish pending ranges and stop held input before changing videos. + + Without this boundary, a range started in one arena could end in the + next. Timer cancellation also prevents delayed key handlers leaking. + """ + player = self.video_player + if self.video_path and not self.view_only_mode: + for behavior, enabled in list(player.range_labeling_active.items()): + if enabled: + start = player.range_labeling_start.get(behavior, player.current_frame) + apply_range_label(self.annotations, behavior, start, player.current_frame, + player.available_behaviors, player.include_last_frame_in_range, + player.multitrack_enabled) + for name in ("navigation_timer", "navigation_delay_timer", "deferred_seek_timer", "scrubbing_timer", "preload_timer"): + getattr(player, name).stop() + for name in ("hold_timers", "clear_timers"): + for timer in getattr(player, name).values(): + timer.stop() + getattr(player, name).clear() + for name in ("active_labels", "is_toggled_active", "is_stopping_toggle", "label_key_held", + "range_labeling_active", "range_labeling_start", "key_press_start_time"): + getattr(player, name).clear() + player.held_behavior = player.current_label_behavior = player.last_created_label = None + player.removing_mode = player.left_key_held = player.right_key_held = False + player._qt_escape_held = player._pygame_escape_held = False + player.navigation_direction = 0 + player.gamepad_frame_accumulator = 0 + self.timeline.clear_range_preview() + for button in self.behavior_buttons.buttons: + button.button.setChecked(False) + + def prepare_video_change(self): + """Save the current arena before switching; failed saves cancel the switch.""" + self._settle_arena_input() + if self.video_path and not self.view_only_mode and (self.active_arena() is not None or self.annotations): + self.save_annotations() + + def persist_arena_progress(self): + """Persist the reviewed status and last frame using the atomic manifest.""" + arena = self.active_arena() + if arena: + arena.last_frame = max(0, int(self.video_player.current_frame)) + self.arena_project.save(self.arena_project_dir) + + def read_arena_annotations(self, video_path): + """Validate a crop's CSV before replacing the current editor session. + + Opening a file is read-only. Unknown behavior columns are retained and + shown in the editor, rather than silently discarded or synchronized. + Invalid row numbering or values stops the switch and preserves both + the current session and the problematic CSV for inspection. + """ + csv_path = Path(video_path).with_suffix('.csv') + if not csv_path.exists(): + return {}, list(self.behavior_buttons.behaviors) + frame = pd.read_csv(csv_path) + expected_count = self.arena_project.source_metadata['frame_count'] + if 'Frames' not in frame.columns or len(frame) != expected_count: + raise ValueError(f"{csv_path.name} must contain one row for each of the {expected_count} source frames.") + if not np.array_equal(frame['Frames'].to_numpy(), np.arange(1, expected_count + 1)): + raise ValueError(f"{csv_path.name} has missing, duplicate, or reordered frame numbers.") + columns = [column for column in frame.columns if column != 'Frames'] + if not columns or not frame[columns].isin([0, 1]).all().all(): + raise ValueError(f"{csv_path.name} must have behavior columns containing only 0 and 1.") + behaviors = list(dict.fromkeys([*self.behavior_buttons.behaviors, *columns])) + rows, indices = np.where(frame[columns].to_numpy() == 1) + annotations = {} + for row, column in zip(rows, indices): + annotations.setdefault(int(row), []).append(columns[int(column)]) + return annotations, behaviors + + def mark_arena_edited(self): + """A first edit starts work; editing a completed arena reopens it.""" + arena = self.active_arena() + if arena and arena.status != "ongoing": + arena.status = "ongoing" + self.persist_arena_progress() + self.refresh_arena_panel() + + def configure_arenas(self): + """Review arena geometry and export validated, independent subvideos.""" + if not self.video_path or self.arena_export_worker: + return + source = str(self.arena_project.source_path) if self.arena_project else self.video_path + capture = cv2.VideoCapture(source) + ok, frame = capture.read() + capture.release() + if not ok: + QMessageBox.warning(self, "Cannot read source", f"Could not read a preview from {source}") + return + existing = self.arena_project.arenas if self.arena_project else [] + editor = ArenaEditor(source, frame, existing, geometry_locked=bool(existing), parent=self) + if editor.exec() != QDialog.Accepted: + return + try: + self.prepare_video_change() + if existing: + self.arena_project.arenas = editor.arenas + self.arena_project.save(self.arena_project_dir) + self.refresh_arena_panel() + self.update_arena_title() + return + project_dir = default_project_dir(source) + self._pending_arena_project = ArenaProject.create(source, editor.arenas) + self._pending_arena_dir = project_dir + self.arena_progress_dialog = QProgressDialog("Exporting arena videos. Source frames remain unchanged.", "Cancel", 0, 100, self) + self.arena_progress_dialog.setWindowTitle("Ethoscore | Export arenas") + self.arena_progress_dialog.setWindowModality(Qt.WindowModal) + self.arena_progress_dialog.setMinimumDuration(0) + self.arena_progress_dialog.setAutoClose(False) + self.arena_progress_dialog.setAutoReset(False) + self.arena_export_worker = ExportWorker(source, editor.arenas, project_dir, editor.lossless, self) + self.arena_export_worker.progress.connect(self.arena_progress_dialog.setValue) + self.arena_progress_dialog.canceled.connect(self.arena_export_worker.requestInterruption) + self.arena_export_worker.exported.connect(self.arena_export_succeeded) + self.arena_export_worker.failed.connect(self.arena_export_failed) + self.arena_export_worker.finished.connect(self.arena_export_finished) + self.arena_export_worker.start() + self.arena_progress_dialog.show() + except Exception as error: + QMessageBox.warning(self, "Arena setup failed", str(error)) + + def arena_export_succeeded(self, paths): + """Publish the project only after the exporter has verified every crop.""" + try: + self.arena_project = ArenaProject.load(self._pending_arena_dir) + self.arena_project_dir = self._pending_arena_dir + self.active_arena_id = None + self.refresh_arena_panel() + self.switch_arena(self.arena_project.arenas[0].id) + self.statusBar().showMessage(f"Exported {len(paths)} arenas. Source frame numbers preserved.", 8000) + except Exception as error: + QMessageBox.warning(self, "Could not save arena project", str(error)) + + def arena_export_failed(self, message): + """Report an export failure without redirecting the current annotation session.""" + QMessageBox.warning(self, "Arena export did not finish", message) + + def arena_export_finished(self): + """Release worker ownership after its thread has actually stopped.""" + if self.arena_progress_dialog: + self.arena_progress_dialog.close() + self.arena_progress_dialog.deleteLater() + self.arena_progress_dialog = None + if self.arena_export_worker: + self.arena_export_worker.deleteLater() + self.arena_export_worker = None + + def select_arena_from_combo(self, index): + """Resolve a combo selection by stable ID rather than a mutable label.""" + arena_id = self.arena_selector.itemData(index) + if arena_id and arena_id != self.active_arena_id: + self.switch_arena(arena_id) + + def step_arena(self, direction): + """Cycle through the video's arenas in one click, saving the current one.""" + count = self.arena_selector.count() + if count: + index = (self.arena_selector.currentIndex() + direction) % count + self.switch_arena(self.arena_selector.itemData(index)) + + def switch_arena(self, arena_id): + """Save the current crop and load only the requested arena's video and CSV.""" + if self._loading_arena or not self.arena_project: + return False + target = next((arena for arena in self.arena_project.arenas if arena.id == arena_id), None) + if target is None: + return False + crop_path = target.video_path(self.arena_project_dir) + if not crop_path.is_file(): + QMessageBox.warning(self, "Arena video missing", f"The exported crop was not found:\n{crop_path}") + self.refresh_arena_panel() + return False + previous_id = self.active_arena_id + try: + self.prepare_video_change() + self._loading_arena = True + self.active_arena_id = target.id + self.last_frame_positions[str(crop_path)] = target.last_frame + if not self.load_video_by_path(str(crop_path), arena_switch=True): + self.active_arena_id = previous_id + return False + self.update_arena_title() + self.refresh_arena_panel() + self.video_player.setFocus() + return True + except Exception as error: + self.active_arena_id = previous_id + QMessageBox.warning(self, "Could not switch arena", str(error)) + return False + finally: + self._loading_arena = False + self.refresh_arena_panel() + + def discover_arena_project(self, file_path): + """Open a known project before the full source CSV can be touched. + + Return None for an ordinary video, True on success, or False when an + existing project cannot be safely opened. A failure must not silently + fall back to editing the original source's annotation file. + """ + path = Path(file_path).resolve() + candidates = [default_project_dir(path), path.parent.parent] + for directory in candidates: + # Loading a nonexistent project should be silent. A damaged one is + # reported rather than silently replaced with newly detected crops. + if not (Path(directory) / "arenas.json").is_file(): + continue + try: + project = ArenaProject.load(directory) + source_match = Path(project.source_path).resolve() == path + matching = next((arena for arena in project.arenas + if arena.video_path(directory).resolve() == path), None) + if not source_match and matching is None: + continue + previous = self.arena_project, self.arena_project_dir, self.active_arena_id + self.arena_project, self.arena_project_dir = project, Path(directory) + # The old arena was already saved before project resolution. + # Clearing its ID prevents saving its dictionary into this one. + self.active_arena_id = None + target = matching or (project.arenas[0] if project.arenas else None) + if target and self.switch_arena(target.id): + return True + self.arena_project, self.arena_project_dir, self.active_arena_id = previous + self.refresh_arena_panel() + return False + except Exception as error: + QMessageBox.warning(self, "Could not reopen arena project", str(error)) + return False + self.arena_project = None + self.arena_project_dir = None + self.active_arena_id = None + self.refresh_arena_panel() + self.update_arena_title() + return None + + def update_arena_title(self): + """Show both source and selected arena so the user always knows the scope.""" + arena = self.active_arena() + if arena: + source_name = Path(self.arena_project.source_path).name + self.video_path_label.setText(f"{source_name}\n{arena.label}") + self.video_path_label.setToolTip(str(arena.video_path(self.arena_project_dir))) + self.setWindowTitle(f"Ethoscore | {arena.label} | {source_name}") + self.last_video_path = str(self.arena_project.source_path) + else: + self.setWindowTitle("Ethoscore") + + def change_arena_status(self, index): + """Save completion as an explicit user decision, including all-empty arenas.""" + arena = self.active_arena() + if arena: + previous = arena.status + try: + self._settle_arena_input() + self.save_annotations() + arena.status = self.arena_status_selector.itemData(index) + self.persist_arena_progress() + self.refresh_arena_panel() + except Exception as error: + arena.status = previous + self.refresh_arena_panel() + QMessageBox.warning(self, "Could not save arena status", str(error)) + + def open_arena_folder(self): + """Open the project output directory with the native file manager.""" + if self.arena_project_dir: + QDesktopServices.openUrl(QUrl.fromLocalFile(str(self.arena_project_dir))) diff --git a/annotator_libs/video_handling.py b/annotator_libs/video_handling.py index 2d2181d..7b44b87 100644 --- a/annotator_libs/video_handling.py +++ b/annotator_libs/video_handling.py @@ -9,124 +9,132 @@ from PySide6.QtCore import Qt, QTimer, Signal, QThread, QCoreApplication from PySide6.QtGui import QImage, QPixmap, QPainter, QPen, QColor import threading +import os from collections import OrderedDict import time from annotator_libs.annotation_logic import update_annotations_on_frame_change # Import the function +def _detached_frame_image(frame): + """Copy decoded RGB bytes into a thread-safe, independently owned QImage.""" + rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + height, width, channels = rgb.shape + return QImage(rgb.data, width, height, channels * width, QImage.Format_RGB888).copy() + + class FrameCache: - """LRU cache for video frames to improve performance""" + """Thread-safe QImage LRU bounded by both frame count and decoded bytes.""" - def __init__(self, max_size=500): + def __init__(self, max_size=500, max_bytes=256 * 1024 * 1024): self.cache = OrderedDict() self.max_size = max_size + self.max_bytes = max_bytes + self.current_bytes = 0 self.lock = threading.Lock() def get(self, frame_number): - """Get frame from cache""" + """Get an immutable cached image and mark it recently used.""" with self.lock: - if frame_number in self.cache: - # Move to end (most recently used) + image = self.cache.get(frame_number) + if image is not None: self.cache.move_to_end(frame_number) - return self.cache[frame_number] - return None + return image def put(self, frame_number, frame_data): - """Put frame in cache""" + """Store detached images only; QPixmap belongs to the GUI thread.""" + if not isinstance(frame_data, QImage): + raise TypeError("FrameCache accepts QImage objects only.") + size = frame_data.sizeInBytes() with self.lock: - if frame_number in self.cache: - # Update existing - self.cache.move_to_end(frame_number) - self.cache[frame_number] = frame_data - else: - # Add new - self.cache[frame_number] = frame_data - if len(self.cache) > self.max_size: - # Remove least recently used - self.cache.popitem(last=False) + previous = self.cache.pop(frame_number, None) + if previous is not None: + self.current_bytes -= previous.sizeInBytes() + if size > self.max_bytes or self.max_size < 1: + return + self.cache[frame_number] = frame_data + self.current_bytes += size + while len(self.cache) > self.max_size or self.current_bytes > self.max_bytes: + _, removed = self.cache.popitem(last=False) + self.current_bytes -= removed.sizeInBytes() def clear(self): - """Clear all cached frames""" + """Release decoded image memory when changing videos.""" with self.lock: self.cache.clear() + self.current_bytes = 0 + + def preload_frames(self, video_path, start_frame, num_frames, callback=None): + """Legacy helper with an independent capture; return its joinable thread. + + Callers must pass an explicit filename, never a shared VideoCapture. + VideoPlayer uses the cancelable FramePreloader instead. + """ + if not isinstance(video_path, (str, os.PathLike)): + raise TypeError("Preloading requires an explicit source video path.") - def preload_frames(self, video_capture, start_frame, num_frames, callback=None): - """Preload frames in background""" def preload_worker(): - for i in range(num_frames): - frame_num = start_frame + i - if frame_num >= 0 and frame_num < int(video_capture.get(cv2.CAP_PROP_FRAME_COUNT)): - # Check if already cached - if self.get(frame_num) is None: - # Read and cache frame - video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_num) - ret, frame = video_capture.read() - if ret: - # Convert BGR to RGB - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # Convert to QImage - h, w, ch = frame_rgb.shape - bytes_per_line = ch * w - q_img = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) - # Store as QPixmap for faster display - pixmap = QPixmap.fromImage(q_img) - self.put(frame_num, pixmap) - if callback: - callback(frame_num) + """Own and release the decoder entirely within this worker.""" + capture = cv2.VideoCapture(str(video_path)) + try: + capture.set(cv2.CAP_PROP_POS_FRAMES, start_frame) + for frame_number in range(start_frame, start_frame + num_frames): + ok, frame = capture.read() + if not ok: + break + self.put(frame_number, _detached_frame_image(frame)) + if callback: + callback(frame_number) + finally: + capture.release() thread = threading.Thread(target=preload_worker, daemon=True) thread.start() + return thread class FramePreloader(QThread): - """Background thread for preloading frames""" + """Preload detached images with a worker-owned, independent decoder.""" - frame_loaded = Signal(int, int, int) # Signal emitted when a frame is loaded (current, total, total_frames_to_preload) - preload_finished = Signal() # Signal emitted when preloading is complete + frame_loaded = Signal(int, int, int) + preload_finished = Signal() - def __init__(self, video_capture, cache, start_frame, num_frames_to_preload, total_frames_in_video, lock): + def __init__(self, video_path, cache, start_frame, num_frames_to_preload, total_frames_in_video): super().__init__() - self.video_capture = video_capture + self.video_path = str(video_path) self.cache = cache self.start_frame = start_frame self.num_frames_to_preload = num_frames_to_preload self.total_frames_in_video = total_frames_in_video - self.lock = lock - self._is_running = True # Flag to control thread execution + self._stop_requested = threading.Event() def stop(self): - self._is_running = False + """Request cancellation between decoder operations.""" + self._stop_requested.set() def run(self): - """Preload frames in background thread""" - preloaded_count = 0 + """Read sequentially without changing the foreground decoder position.""" + capture = cv2.VideoCapture(self.video_path) last_read_frame = -1 - for i in range(self.num_frames_to_preload): - if not self._is_running: - break - frame_num = self.start_frame + i - if frame_num >= 0 and frame_num < self.total_frames_in_video: - # Check if already cached - if self.cache.get(frame_num) is None: - # Read and cache frame - with self.lock: - if frame_num != last_read_frame + 1: - self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, frame_num) - ret, frame = self.video_capture.read() - if ret: - last_read_frame = frame_num - if ret: - # Convert BGR to RGB - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - # Convert to QImage - h, w, ch = frame_rgb.shape - bytes_per_line = ch * w - q_img = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) - # Store as QPixmap for faster display - pixmap = QPixmap.fromImage(q_img) - self.cache.put(frame_num, pixmap) - preloaded_count += 1 - self.frame_loaded.emit(preloaded_count, self.num_frames_to_preload, self.total_frames_in_video) - self.preload_finished.emit() # Emit when preloading is done + try: + if not capture.isOpened(): + return + for offset in range(self.num_frames_to_preload): + if self._stop_requested.is_set(): + break + frame_number = self.start_frame + offset + if not 0 <= frame_number < self.total_frames_in_video: + break + if self.cache.get(frame_number) is None: + if frame_number != last_read_frame + 1: + capture.set(cv2.CAP_PROP_POS_FRAMES, frame_number) + ok, frame = capture.read() + if not ok: + break + last_read_frame = frame_number + self.cache.put(frame_number, _detached_frame_image(frame)) + self.frame_loaded.emit(offset + 1, self.num_frames_to_preload, self.total_frames_in_video) + finally: + capture.release() + self.preload_finished.emit() class VideoPlayer(QLabel): @@ -239,6 +247,7 @@ def __init__(self, timeline=None): self.frame_cache = FrameCache(max_size=500) self.preloader = None self.preload_timer = QTimer() + self.preload_timer.setSingleShot(True) self.preload_timer.timeout.connect(self.start_preloading) self.preload_range = 200 # Preload frames ahead and behind self.is_caching_finished = False # Flag to track caching status @@ -370,56 +379,93 @@ def set_include_last_frame_in_range(self, enabled): """Set whether to include the last selected frame in range labeling""" self.include_last_frame_in_range = enabled - def load_video(self, video_path): - """Load a video file""" - # Stop any existing preloader gracefully before releasing video capture - if self.preloader and self.preloader.isRunning(): + def _stop_decoder(self): + """Join the preloader before releasing video resources or clearing cache.""" + self.preload_timer.stop() + if self.preloader is not None: self.preloader.stop() self.preloader.wait() - - if self.video_capture: + self.preloader = None + if self.video_capture is not None: with self.video_capture_lock: self.video_capture.release() + self.video_capture = None + self.frame_cache.clear() + self.last_read_frame = -1 - with self.video_capture_lock: - self.video_capture = cv2.VideoCapture(video_path) - if not self.video_capture.isOpened(): - from PySide6.QtWidgets import QMessageBox - QMessageBox.warning(self, "Error", f"Could not open video: {video_path}") - return False + def _reset_input_state(self): + """Prevent held keys, delayed labels, and seeks crossing arena boundaries.""" + for timer in (self.navigation_timer, self.navigation_delay_timer, + self.scrubbing_timer, self.deferred_seek_timer, + *self.hold_timers.values(), *self.clear_timers.values()): + timer.stop() + for name in ('active_labels', 'key_press_start_time', 'is_toggled_active', + 'is_stopping_toggle', 'label_key_held', 'range_labeling_active', + 'range_labeling_start', 'gamepad_button_states'): + getattr(self, name).clear() + self.hold_timers.clear() + self.clear_timers.clear() + self.navigation_direction = 0 + self.last_navigation_direction = 0 + self.navigation_start_time = None + self.left_key_held = self.right_key_held = False + self.labeling_mode = self.removing_mode = self.fast_mode_active = False + self._qt_escape_held = self._pygame_escape_held = False + self.is_scrubbing = False + self.held_behavior = self.current_behavior = self.current_label_behavior = None + self.last_created_label = None + self._undo_pushed_for_current_action = False + self.gamepad_frame_accumulator = 0.5 + if self.timeline: + self.timeline.clear_range_preview() - self.video_path = video_path - self.total_frames = int(self.video_capture.get(cv2.CAP_PROP_FRAME_COUNT)) - self.frame_rate = self.video_capture.get(cv2.CAP_PROP_FPS) + def shutdown(self): + """Stop input timers and join decoder workers before closing the widget.""" + self._reset_input_state() + self._stop_decoder() + self.video_path = "" + self.total_frames = 0 self.current_frame = 0 - self.last_read_frame = -1 - self.active_labels = {} # Reset active labels - self.held_behavior = None # Reset held behavior - self.current_behavior = None # Reset current behavior - - # Clear frame cache for new video - self.frame_cache.clear() - self.is_caching_finished = False # Reset caching status for new video - # Setup progress bar - self.progress_bar.setMinimum(0) - self.progress_bar.setMaximum(self.total_frames) - self.progress_bar.setVisible(True) # Make progress bar visible - self.progress_bar.setValue(self.current_frame + 1) # Set initial value (1-based) + def load_video(self, video_path, total_frames=None): + """Open a movie, optionally using an independently verified frame count. - # Position the progress bar + Exact counts from arena export override approximate container counts + before progress setup and background preloading begin. + """ + if total_frames is not None and (type(total_frames) is not int or total_frames < 1): + raise ValueError("Known video frame count must be a positive integer.") + # Validate a replacement independently. A corrupt crop must never + # release the old decoder or erase its cached image and held state. + replacement = cv2.VideoCapture(str(video_path)) + valid = replacement.isOpened() + ok, first_frame = replacement.read() if valid else (False, None) + replacement_count = total_frames if total_frames is not None else int(replacement.get(cv2.CAP_PROP_FRAME_COUNT)) + if not valid or not ok or replacement_count < 1: + replacement.release() + from PySide6.QtWidgets import QMessageBox + QMessageBox.warning(self, "Error", f"Could not open a readable video: {video_path}") + return False + first_image = _detached_frame_image(first_frame) + replacement_rate = replacement.get(cv2.CAP_PROP_FPS) + self._reset_input_state() + self._stop_decoder() + self.video_path = str(video_path) + self.video_capture = replacement + self.total_frames = replacement_count + self.frame_rate = replacement_rate + self.current_frame = 0 + self.last_read_frame = 0 + self.frame_cache.put(0, first_image) + self.is_caching_finished = False + self.progress_bar.setRange(0, self.total_frames) + self.progress_bar.setVisible(True) + self.progress_bar.setValue(1) self.resizeEvent(None) - self.update_frame_display() - - # Set target dimensions to current label size for stable scaling self.target_width = self.width() self.target_height = self.height() - - # Start preloading frames around current position self.start_preloading() - - # Set focus to enable keyboard navigation self.setFocus() return True @@ -428,40 +474,23 @@ def update_frame_display(self): if not self.video_capture or not self.video_capture.isOpened(): return - # Try to get frame from cache first - cached_pixmap = self.frame_cache.get(self.current_frame) - - if cached_pixmap is None: - # Frame not in cache, read from disk + image = self.frame_cache.get(self.current_frame) + if image is None: with self.video_capture_lock: - # Optimization: only set position if not sequential - if self.current_frame != self.last_read_frame + 1: + # Check the actual decoder position as well as our sequential + # hint. Cache hits do not advance the foreground decoder. + position = self.video_capture.get(cv2.CAP_PROP_POS_FRAMES) + if self.current_frame != self.last_read_frame + 1 or abs(position - self.current_frame) > 0.5: self.video_capture.set(cv2.CAP_PROP_POS_FRAMES, self.current_frame) - - ret, frame = self.video_capture.read() - if ret: - self.last_read_frame = self.current_frame - else: + ok, frame = self.video_capture.read() + if not ok: return - - # Convert BGR to RGB - frame_rgb = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) - - # Convert to QImage - h, w, ch = frame_rgb.shape - bytes_per_line = ch * w - q_img = QImage(frame_rgb.data, w, h, bytes_per_line, QImage.Format_RGB888) - - # Create base pixmap - pixmap = QPixmap.fromImage(q_img) - - # Cache the base pixmap (without labels) - self.frame_cache.put(self.current_frame, pixmap.copy()) - else: - # Use cached frame - pixmap = cached_pixmap.copy() - # Get dimensions from cached pixmap - w, h = pixmap.width(), pixmap.height() + self.last_read_frame = self.current_frame + image = _detached_frame_image(frame) + self.frame_cache.put(self.current_frame, image) + # QPixmap allocation and scaling happen exclusively on the GUI thread. + pixmap = QPixmap.fromImage(image) + w, h = image.width(), image.height() # Emit frame changed before drawing to update current_behavior self.frame_changed.emit(self.current_frame) @@ -538,27 +567,35 @@ def start_preloading(self): self.preloader.wait() # Calculate preload range around current frame - start_frame = max(0, self.current_frame - self.preload_range) - end_frame = min(self.total_frames, self.current_frame + self.preload_range + 1) + bytes_per_frame = max(1, int(self.video_capture.get(cv2.CAP_PROP_FRAME_WIDTH)) * + int(self.video_capture.get(cv2.CAP_PROP_FRAME_HEIGHT)) * 3) + capacity = max(1, min(self.frame_cache.max_size, self.frame_cache.max_bytes // bytes_per_frame)) + radius = min(self.preload_range, max(0, (capacity - 1) // 2)) + start_frame = max(0, self.current_frame - radius) + end_frame = min(self.total_frames, self.current_frame + radius + 1) self.total_frames_to_preload = end_frame - start_frame self.current_preloaded_count = 0 # Start new preloader - self.preloader = FramePreloader(self.video_capture, self.frame_cache, start_frame, - self.total_frames_to_preload, self.total_frames, self.video_capture_lock) + self.preloader = FramePreloader(self.video_path, self.frame_cache, start_frame, + self.total_frames_to_preload, self.total_frames) self.preloader.frame_loaded.connect(self.on_frame_preloaded) self.preloader.preload_finished.connect(self._on_preload_finished) # Connect preload_finished signal self.preloader.start() def on_frame_preloaded(self, current_preloaded, total_to_preload, total_frames_in_video): """Handle when a frame has been preloaded""" + if self.sender() is not self.preloader: + return self.current_preloaded_count = current_preloaded self.preload_progress.emit(self.current_preloaded_count, total_to_preload) def _on_preload_finished(self): """Handle when preloading is complete""" - self.is_caching_finished = True + if self.sender() is not self.preloader: + return + self.is_caching_finished = self.total_frames_to_preload >= self.total_frames self.caching_complete.emit() self.preload_finished.emit() # Emit the new signal self.preload_timer.stop() # Stop the timer to prevent further preloading @@ -583,10 +620,15 @@ def resizeEvent(self, event): self.scale_and_set_pixmap() # Reposition widgets - if hasattr(self, 'progress_bar') and self.progress_bar.isVisible(): + # Set geometry even before the main window is shown. Otherwise the + # initial welcome-dialog load leaves this bar over the top-left labels. + if hasattr(self, 'progress_bar'): progress_height = 25 self.progress_bar.setGeometry(10, self.height() - progress_height - 10, self.width() - 20, progress_height) + if hasattr(self, 'score_overlay'): + overlay = self.score_overlay + overlay.move(24, max(10, self.height() - overlay.height() - 50)) def update_overlay_preview_bars(self): """Update preview bars in the annotation overlay and return their colors""" diff --git a/ethoscore.py b/ethoscore.py index b46f991..5e0f634 100644 --- a/ethoscore.py +++ b/ethoscore.py @@ -43,6 +43,7 @@ def resource_path(relative_path): get_default_behaviors ) from annotator_libs.gamification_logic import GamificationManager, LiveScoreWidget, GamificationSettingsDialog +from annotator_libs.arena_workflow import ArenaWorkflow # Suppress Qt warnings os.environ['QT_LOGGING_RULES'] = '*.warning=false' @@ -859,7 +860,7 @@ def rescan_controllers(self): # For now, just update the internal state. The dialog would need to be closed and reopened to see changes. -class VideoAnnotator(QMainWindow): +class VideoAnnotator(ArenaWorkflow, QMainWindow): """Main application window""" def __init__(self): @@ -868,6 +869,7 @@ def __init__(self): self.annotations = {} # frame_number -> list of behaviors self.current_behavior = [] self.view_only_mode = False # Flag to track if in view-only mode + self.init_arena_workflow() # Initialize shortcuts first self.shortcuts = {} self.undo_stack = [] # Stack for undoing annotations @@ -1008,6 +1010,7 @@ def video_player_keyPressEvent(event): # Add LiveScoreWidget as an overlay to the video player self.live_score_widget.setParent(self.video_player) # Make it a child of video_player + self.video_player.score_overlay = self.live_score_widget self.live_score_widget.show() # Make sure it's visible # Initial positioning will be handled by resizeEvent @@ -1025,7 +1028,10 @@ def video_player_keyPressEvent(event): # Timer for gamepad polling self.gamepad_timer = QTimer(self) - self.gamepad_timer.timeout.connect(self.video_player.process_gamepad_input) + # Modal setup/export dialogs must not leave gamepad labeling active. + self.gamepad_timer.timeout.connect( + lambda: self.video_player.process_gamepad_input() if QApplication.activeModalWidget() is None else None + ) self.gamepad_timer.start(50) # Poll every 50ms (20 FPS) # Right panel - behavior buttons @@ -1103,6 +1109,9 @@ def video_player_keyPressEvent(event): right_panel.addWidget(file_group) + # Arena controls reuse the existing editor with independent crop files. + right_panel.addWidget(self.create_arena_panel()) + self.behavior_buttons = BehaviorButtons() self.behavior_buttons.behavior_toggled.connect(self.on_behavior_toggled) self.behavior_buttons.behavior_added.connect(self.on_behavior_added) @@ -1177,6 +1186,8 @@ def push_undo_state(self): if self.view_only_mode: return + self.mark_arena_edited() + # Deep copy current state state = {frame: list(behaviors) for frame, behaviors in self.annotations.items()} @@ -1254,6 +1265,15 @@ def create_menu_bar(self): save_action.setShortcut(QKeySequence(self.shortcuts.get('save', 'Ctrl+S'))) save_action.triggered.connect(self.save_annotations) + arenas_menu = menubar.addMenu('Arenas') + setup_arenas_action = arenas_menu.addAction('Set Up / Manage Arenas') + setup_arenas_action.triggered.connect(self.configure_arenas) + previous_arena_action = arenas_menu.addAction('Previous Arena') + previous_arena_action.triggered.connect(lambda: self.step_arena(-1)) + next_arena_action = arenas_menu.addAction('Next Arena') + next_arena_action.triggered.connect(lambda: self.step_arena(1)) + arenas_menu.addAction('Open Arena Folder').triggered.connect(self.open_arena_folder) + # Settings menu settings_menu = menubar.addMenu('Settings') @@ -1808,20 +1828,50 @@ def update_timeline_annotations(self): self.timeline.set_annotations(self.annotations, behavior_colors) self.timeline.update() - def load_video_by_path(self, file_path): - """Load video by path""" + def load_video_by_path(self, file_path, arena_switch=False): + """Load a source or an arena crop after safely settling the old session.""" + file_path = os.path.abspath(os.fspath(file_path)) + previous_arena_context = self.arena_project, self.arena_project_dir, self.active_arena_id + # Read and validate the target CSV before replacing a working arena. + # Loading never repairs or rewrites an existing arena annotation file. + arena_annotations, arena_behaviors = (self.read_arena_annotations(file_path) + if arena_switch else (None, None)) + if not arena_switch: + try: + self.prepare_video_change() + except Exception as error: + QMessageBox.warning(self, "Could not save current session", str(error)) + return False + existing_project = self.discover_arena_project(file_path) + if existing_project is not None: + return existing_project # Save current frame position and score for the previous video if self.video_path: self.last_frame_positions[self.video_path] = self.video_player.current_frame self.last_video_scores[self.video_path] = self.gamification_manager.total_score + previous_annotations = self.annotations + previous_view_only = self.view_only_mode + # Reset view-only mode for new video self.view_only_mode = False # Record video load time for statistics self.video_load_time = time.time() - if self.video_player.load_video(file_path): + # A video's first frame emits callbacks immediately. Detach the old + # annotation dictionary before opening it to prevent cross-arena edits. + self.annotations = {} + self.video_player.annotations = self.annotations + + exact_frames = (self.arena_project.source_metadata.get('frame_count') + if arena_switch and self.arena_project else None) + self._loading_video = True + try: + opened = self.video_player.load_video(file_path, total_frames=exact_frames) + finally: + self._loading_video = False + if opened: self.video_path = file_path self.video_path_label.setText(os.path.basename(file_path)) self.annotations = {} # Reset annotations for new video @@ -1832,7 +1882,12 @@ def load_video_by_path(self, file_path): self.gamification_manager.set_total_score(initial_score) # Set the score in the manager # Try to autoload CSV if exists with flexible behavior handling - self.annotations = self.load_annotations_with_behavior_handling(file_path) + if arena_switch: + self.behavior_buttons.load_behaviors(arena_behaviors) + self.video_player.available_behaviors = arena_behaviors + self.annotations = arena_annotations + else: + self.annotations = self.load_annotations_with_behavior_handling(file_path) # Update VideoPlayer with current annotations for overlay preview bars self.video_player.annotations = self.annotations @@ -1871,6 +1926,21 @@ def load_video_by_path(self, file_path): # Save as last video self.last_video_path = file_path self.save_settings() + self.refresh_arena_panel() + self.update_arena_title() + self.save_settings() + return True + # The player validates its replacement decoder before releasing the old + # one. Restore this dictionary too so a failed open cannot erase labels. + self.annotations = previous_annotations + self.video_player.annotations = previous_annotations + self.view_only_mode = previous_view_only + if not arena_switch: + self.arena_project, self.arena_project_dir, self.active_arena_id = previous_arena_context + self.refresh_arena_panel() + self.update_arena_title() + self.video_player.update_frame_display() + return False def load_next_video_in_main_ui(self): """Load the next alphabetical video in the same folder as the currently opened video.""" @@ -1884,7 +1954,8 @@ def load_next_video_in_main_ui(self): dialog = StatisticsDialog(statistics, self) dialog.exec() - current_dir = os.path.dirname(self.video_path) + navigation_source = str(self.arena_project.source_path) if self.arena_project else self.video_path + current_dir = os.path.dirname(navigation_source) video_files = [] for f in os.listdir(current_dir): if f.lower().endswith(('.mp4', '.avi', '.mov', '.mkv', '.wmv')): @@ -1897,7 +1968,7 @@ def load_next_video_in_main_ui(self): return try: - current_video_index = video_files.index(self.video_path) + current_video_index = video_files.index(navigation_source) next_video_index = (current_video_index + 1) % len(video_files) next_video_path = video_files[next_video_index] self.save_annotations() @@ -2288,10 +2359,16 @@ def save_annotations(self): if self.view_only_mode: QMessageBox.information(self, "Preview Only Mode", "Cannot save annotations in view-only mode.") return - save_annotations_to_csv(self.video_path, self.annotations, self.behavior_buttons.behaviors, self.statusBar()) + save_annotations_to_csv(self.video_path, self.annotations, self.behavior_buttons.behaviors, + self.statusBar(), total_frames=self.video_player.total_frames) + self.persist_arena_progress() def auto_save_annotations(self): """Automatically save annotations""" + # A modal decoder error runs a nested event loop. Keep its timer from + # writing the temporarily detached dictionary during a load attempt. + if getattr(self, '_loading_video', False) or self._loading_arena: + return if self.view_only_mode: return # Don't auto-save in view-only mode if self.video_path: # Only auto-save if a video is loaded @@ -2334,6 +2411,20 @@ def on_caching_complete(self): def closeEvent(self, event): """Handle application close event to save settings.""" + if self.arena_export_worker and self.arena_export_worker.isRunning(): + QMessageBox.information(self, "Arena export running", "Cancel the export or wait for it to finish before closing.") + event.ignore() + return + try: + self.prepare_video_change() + except Exception as error: + QMessageBox.warning(self, "Could not save current arena", str(error)) + event.ignore() + return + # Stop workers before Qt destroys their owning widgets. + self.gamepad_timer.stop() + self.auto_save_timer.stop() + self.video_player.shutdown() if self.video_path: # Only save score if a video was loaded self.last_video_scores[self.video_path] = self.gamification_manager.total_score self.save_settings() # Save general settings diff --git a/installer/INSTALLATION.md b/installer/INSTALLATION.md new file mode 100644 index 0000000..0585208 --- /dev/null +++ b/installer/INSTALLATION.md @@ -0,0 +1,61 @@ +# Ethoscore installation and updates + +Download **Ethoscore-Setup** for your operating system and processor from the +[official releases](https://github.com/BelloneLab/ethoscore/releases). +The graphical installer includes Python, Qt, the video decoder, and all application +dependencies. Git and a separate Python installation are not required. + +1. Open Setup and choose an installation folder. The default is a writable folder + for your account, without administrator privileges. +2. Choose the bundled application for an offline install, or download the latest + official build. Choose desktop and Start menu / Applications shortcuts. +3. Install, then select **Launch Ethoscore**. The shortcuts open the installed app. + +To update, reopen Setup, choose your existing installation folder, then select +**Download the latest official build** and **Update / Reinstall**. Downloaded +application packages are checked against the SHA-256 published with that release. +The bundled option reinstalls that Setup's version and can also restore an older +version. The displayed version identifies what is installed. + +Each update stages a fresh application directory and activates it after validation. +It does not modify an already-running version. Relaunch to use the update. Old +application versions are retained for recovery. Your source checkout, original +videos, arena folders, annotation files, and application preferences are not +deleted. A nonempty folder without an Ethoscore install marker is rejected. +Installing over a Git checkout is intentionally unsupported. + +Windows shortcuts use the actual Desktop Known Folder and your personal Start +menu. macOS shortcuts appear in `~/Applications` and optionally on the Desktop. +Linux installs a freedesktop launcher under the user Applications menu and +optionally on the Desktop. Some Linux desktops require marking a launcher as +trusted through their file manager before double-clicking it. + +## Building native installers + +Use Python 3.12 and install `requirements.txt` plus `pyinstaller>=6.11` in a dedicated +environment, then run from the repository root: + +```console +python -m installer.build --version 2026.10.19 +``` + +Results appear under `dist/releases`: a setup executable, a native application +archive, and its checksum. Build on Windows for Windows, macOS for macOS, and Linux +for Linux. GitHub Actions builds Windows x86-64, Linux x86-64, and macOS arm64 and +x86-64 using native runners. The macOS Setup is distributed as a ZIP containing its +application bundle. Extract the ZIP before opening Setup. Linux Setup is a native +executable; mark it executable with `chmod +x Ethoscore-Setup-Linux-x86_64`. + +From a development checkout, `python -m installer` opens the same interface. +Without a locally built bundled payload, it offers official online release +downloads. Source code is never copied over an arbitrary checkout by Setup. + +The build currently creates unsigned packages. Signing certificates and Apple +notarization credentials must be configured by the publisher before distributing +signed releases. SHA-256 checks detect corruption; they do not replace publisher +code signing. Linux binaries target the GitHub runner's glibc baseline. macOS and +Linux release builds require their CI/native smoke checks; Windows testing alone +cannot establish compatibility with those systems. + +Packaging follows the maintained +[PyInstaller native build documentation](https://www.pyinstaller.org/en/stable/usage.html). diff --git a/installer/__init__.py b/installer/__init__.py new file mode 100644 index 0000000..af451d3 --- /dev/null +++ b/installer/__init__.py @@ -0,0 +1 @@ +"""Native, per-user Ethoscore installation and update tools.""" diff --git a/installer/__main__.py b/installer/__main__.py new file mode 100644 index 0000000..f07877e --- /dev/null +++ b/installer/__main__.py @@ -0,0 +1,6 @@ +"""Launch setup with ``python -m installer`` from a source checkout.""" + +from installer.gui import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/installer/app_entry.py b/installer/app_entry.py new file mode 100644 index 0000000..1ee68b0 --- /dev/null +++ b/installer/app_entry.py @@ -0,0 +1,105 @@ +"""Run Ethoscore with a writable data directory for packaged installations.""" + +from pathlib import Path +import os +import runpy +import sys + + +def smoke_test(report_path: Path) -> None: + """Check the packaged GUI, resources, and FFmpeg using isolated test settings.""" + import json + import contextlib + import subprocess + import tempfile + + os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") + os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + from PySide6.QtCore import QSettings + from PySide6.QtWidgets import QApplication + from PySide6.QtGui import QFontDatabase, QFont + with tempfile.TemporaryDirectory(prefix="ethoscore-native-smoke-") as temporary, contextlib.chdir(temporary): + QSettings.setDefaultFormat(QSettings.IniFormat) + QSettings.setPath(QSettings.IniFormat, QSettings.UserScope, temporary) + app = QApplication(["Ethoscore smoke test"]) + if sys.platform == "win32": + QFontDatabase.addApplicationFont(str(Path(os.environ.get("SystemRoot", "C:/Windows")) / "Fonts/segoeui.ttf")) + app.setFont(QFont("Segoe UI", 10)) + if not getattr(sys, "frozen", False): + # Source runs need the same explicit resource base as frozen builds. + sys._MEIPASS = str(Path(__file__).resolve().parents[1]) + import ethoscore + import imageio_ffmpeg + import cv2 + import numpy as np + from annotator_libs.arena_model import Arena, default_project_dir, detect_arenas + from annotator_libs.arena_export import export_arenas + window = ethoscore.VideoAnnotator() + window.show() + app.processEvents() + resource = Path(ethoscore.resource_path("assets/controller-logo.svg")) + if not resource.is_file(): + raise RuntimeError("The packaged SVG assets could not be found.") + ffmpeg = imageio_ffmpeg.get_ffmpeg_exe() + completed = subprocess.run([ffmpeg, "-version"], capture_output=True, text=True, check=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + source = Path(temporary) / "synthetic-two-arenas.avi" + writer = cv2.VideoWriter(str(source), cv2.VideoWriter_fourcc(*"MJPG"), 12, (128, 64)) + if not writer.isOpened(): + raise RuntimeError("The packaged OpenCV video writer could not open.") + preview = np.full((64, 128, 3), 210, dtype=np.uint8) + preview[:, 63:66] = 10 + for index in range(12): + frame = preview.copy() + cv2.circle(frame, (10 + index * 2, 24), 4, (25, 25, 25), -1) + cv2.circle(frame, (88 + index, 44), 4, (25, 25, 25), -1) + writer.write(frame) + writer.release() + proposals = detect_arenas(preview) + if len(proposals) != 2: + raise RuntimeError("The packaged arena detector failed its two-arena fixture.") + arenas = [Arena("left", "Left arena", 0, 0, 64, 64), Arena("right", "Right arena", 64, 0, 64, 64)] + export_arenas(source, arenas, default_project_dir(source)) + if not window.load_video_by_path(str(source)) or window.active_arena_id != "left": + raise RuntimeError("The packaged arena project could not reopen its first crop.") + behavior = window.behavior_buttons.behaviors[0] + window.annotations[0] = [behavior] + if not window.switch_arena("right") or any(window.annotations.values()): + raise RuntimeError("Annotations leaked between packaged arena sessions.") + if not window.switch_arena("left") or behavior not in window.annotations.get(0, []): + raise RuntimeError("The packaged arena switch did not restore saved annotations.") + if window.video_player.total_frames != 12: + raise RuntimeError("The packaged arena crop changed the decoded frame count.") + report_path.parent.mkdir(parents=True, exist_ok=True) + window.grab().save(str(report_path.with_suffix(".png"))) + report_path.write_text(json.dumps({"status": "passed", "frozen": bool(getattr(sys, "frozen", False)), + "window": window.windowTitle(), "ffmpeg": completed.stdout.splitlines()[0], + "assets": str(resource), "arenas": {"detected": len(proposals), "exported": 2, + "frames": 12, "annotation_isolation": True, "switch_restoration": True}}, indent=2), encoding="utf-8") + window.close() + app.processEvents() + + +def main() -> None: + """Create a per-user working folder and execute the bundled application code.""" + if len(sys.argv) == 3 and sys.argv[1] == "--smoke-test": + smoke_test(Path(sys.argv[2]).resolve()) + return + # Resource files remain in _MEIPASS; writable exports default to user data. + if sys.platform == "win32": + base = Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) + elif sys.platform == "darwin": + base = Path.home() / "Library/Application Support" + else: + base = Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) + current = Path.cwd() + data = current if current.name == "user-data" and (current.parent / ".ethoscore-install.json").is_file() else base / "Ethoscore/user-data" + data.mkdir(parents=True, exist_ok=True) + os.chdir(data) + # The build spec lists ethoscore as a hidden import so runpy can preserve the + # existing __main__ startup sequence without importing the module twice. + runpy.run_module("ethoscore", run_name="__main__") + + +if __name__ == "__main__": + main() diff --git a/installer/application.spec b/installer/application.spec new file mode 100644 index 0000000..84f0b9d --- /dev/null +++ b/installer/application.spec @@ -0,0 +1,24 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Lean native application payload, relying on the maintained package hooks.""" + +from pathlib import Path +import sys +from PyInstaller.utils.hooks import collect_data_files + +root = Path(SPECPATH).parent +a = Analysis( + [str(root / 'installer/app_entry.py')], + pathex=[str(root)], + datas=[(str(root / 'assets'), 'assets')] + collect_data_files('imageio_ffmpeg'), + hiddenimports=['ethoscore', 'PySide6.QtSvg', 'PySide6.QtSvgWidgets'], + excludes=['tkinter', 'matplotlib', 'IPython', 'PyQt5', 'PyQt6', 'PySide6.QtWebEngineCore', + 'PySide6.QtWebEngineWidgets', 'PySide6.QtQml', 'PySide6.QtQuick'], + noarchive=False, +) +pyz = PYZ(a.pure) +exe = EXE(pyz, a.scripts, [], exclude_binaries=True, name='Ethoscore', + console=False, debug=False, strip=False, upx=False) +collection = COLLECT(exe, a.binaries, a.datas, name='Ethoscore', strip=False, upx=False) +if sys.platform == 'darwin': + app = BUNDLE(collection, name='Ethoscore.app', bundle_identifier='org.bellonelab.ethoscore', + info_plist={'CFBundleName': 'Ethoscore', 'NSHighResolutionCapable': True}) diff --git a/installer/backend.py b/installer/backend.py new file mode 100644 index 0000000..43b43d1 --- /dev/null +++ b/installer/backend.py @@ -0,0 +1,306 @@ +"""Transactional application installation, verified downloads, and OS shortcuts. + +Only fresh version directories are written. Existing versions and user files are +never removed, so updating an application that is still open is safe. +""" + +from __future__ import annotations + +import contextlib +import hashlib +import json +import os +from pathlib import Path +import platform +import shlex +import shutil +import subprocess +import tarfile +import tempfile +from typing import Callable +import urllib.request +import uuid + +REPOSITORY = "BelloneLab/ethoscore" +MARKER = ".ethoscore-install.json" +MAX_DOWNLOAD = 3 * 1024**3 +MAX_UNPACKED = 6 * 1024**3 + + +def platform_key() -> str: + """Return the release artifact identifier for this operating system/CPU.""" + system = {"Windows": "Windows", "Darwin": "macOS", "Linux": "Linux"}.get(platform.system()) + machine = platform.machine().lower() + arch = {"amd64": "x86_64", "x86_64": "x86_64", "arm64": "arm64", "aarch64": "arm64"}.get(machine) + if not system or not arch: + raise RuntimeError("This system architecture is not supported by this installer.") + return f"{system}-{arch}" + + +def default_destination() -> Path: + """Choose a writable per-user installation directory without administrator rights.""" + if os.name == "nt": + return Path(os.environ.get("LOCALAPPDATA", Path.home() / "AppData/Local")) / "Programs/Ethoscore" + if platform.system() == "Darwin": + return Path.home() / "Library/Application Support/Ethoscore" + return Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "ethoscore" + + +def read_install(destination: Path) -> dict | None: + """Read a managed install, rejecting corrupt or unrelated installation markers.""" + marker = destination / MARKER + if not marker.exists(): + return None + state = json.loads(marker.read_text(encoding="utf-8")) + if state.get("application") != "Ethoscore" or state.get("schema") != 1: + raise ValueError("The destination contains an unsupported installation manifest.") + return state + + +def atomic_text(path: Path, text: str) -> None: + """Replace a small control file atomically, keeping partial writes invisible.""" + temporary = path.with_name(path.name + "." + uuid.uuid4().hex + ".tmp") + temporary.write_text(text, encoding="utf-8") + os.replace(temporary, path) + + +def _trusted_release_url(url: str) -> None: + """Allow application downloads only from this project's GitHub release assets.""" + if not url.startswith(f"https://github.com/{REPOSITORY}/releases/download/"): + raise ValueError("The release asset URL does not belong to the official repository.") + + +def download(url: str, target: Path, progress: Callable[[str], None] = lambda _: None) -> None: + """Stream an HTTPS download with a bounded size and no shell interpretation.""" + _trusted_release_url(url) + request = urllib.request.Request(url, headers={"User-Agent": "Ethoscore-Installer"}) + with urllib.request.urlopen(request, timeout=45) as response, target.open("wb") as output: + total = int(response.headers.get("Content-Length", 0)) + if total > MAX_DOWNLOAD: + raise ValueError("The application download exceeds the supported size.") + received = 0 + next_report = 0 + while chunk := response.read(1024 * 1024): + received += len(chunk) + if received > MAX_DOWNLOAD: + raise ValueError("The application download exceeds the supported size.") + output.write(chunk) + if received >= next_report: + progress(f"Downloading application: {received // (1024 * 1024)} MB" + + (f" / {total // (1024 * 1024)} MB" if total else "")) + next_report = received + 8 * 1024**2 + + +def latest_release() -> dict: + """Find the continuous release first, falling back to the latest stable tag.""" + last_error = None + for endpoint in ("tags/latest", "latest"): + try: + request = urllib.request.Request(f"https://api.github.com/repos/{REPOSITORY}/releases/{endpoint}", + headers={"User-Agent": "Ethoscore-Installer", "Accept": "application/vnd.github+json"}) + with urllib.request.urlopen(request, timeout=25) as response: + release = json.load(response) + name = f"Ethoscore-{platform_key()}.tar.gz" + assets = {asset["name"]: asset for asset in release["assets"]} + if name not in assets or name + ".sha256" not in assets: + raise RuntimeError(f"This release has no {platform_key()} application package yet.") + return {"version": release["tag_name"], "name": name, + "url": assets[name]["browser_download_url"], + "checksum_url": assets[name + ".sha256"]["browser_download_url"]} + except Exception as error: + last_error = error + raise RuntimeError(f"Cannot find a compatible official release: {last_error}") + + +def download_release(release: dict, directory: Path, progress: Callable[[str], None]) -> Path: + """Download the platform payload and verify it against the published SHA-256.""" + archive, checksum = directory / release["name"], directory / "checksum.txt" + download(release["checksum_url"], checksum, progress) + expected = checksum.read_text(encoding="utf-8").split()[0].lower() + if len(expected) != 64 or any(char not in "0123456789abcdef" for char in expected): + raise ValueError("The release checksum is invalid.") + download(release["url"], archive, progress) + digest = hashlib.sha256() + with archive.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + if digest.hexdigest() != expected: + raise ValueError("The downloaded application failed its SHA-256 integrity check.") + return archive + + +def extract_payload(archive: Path, destination: Path) -> dict: + """Validate and extract a native payload without permitting archive traversal.""" + with tarfile.open(archive, "r:gz") as bundle: + members = bundle.getmembers() + if sum(member.size for member in members) > MAX_UNPACKED: + raise ValueError("The unpacked application exceeds the supported size.") + for member in members: + target = (destination / member.name).resolve() + if not target.is_relative_to(destination.resolve()) or member.isdev() or member.isfifo(): + raise ValueError("The application archive contains an unsafe path or device.") + # Python's data filter also rejects links pointing outside the payload. + bundle.extractall(destination, filter="data") + metadata = json.loads((destination / "payload.json").read_text(encoding="utf-8")) + executable = (destination / metadata["executable"]).resolve() + if metadata.get("application") != "Ethoscore" or metadata.get("platform") != platform_key(): + raise ValueError("The payload does not match this platform or application.") + if not executable.is_relative_to(destination.resolve()) or not executable.is_file(): + raise ValueError("The payload has no valid application executable.") + if os.name != "nt": + executable.chmod(executable.stat().st_mode | 0o111) + return metadata + + +def install_payload(archive: Path, destination: Path, desktop: bool = True, menu: bool = True, + progress: Callable[[str], None] = lambda _: None, + shortcuts: bool = True) -> dict: + """Stage a complete version, then atomically activate it and refresh shortcuts. + + The current application and user-data folder remain intact on any extraction + failure. A new immutable version folder also allows an old app to stay open. + """ + destination = destination.expanduser().resolve() + state = read_install(destination) + if destination.exists() and any(destination.iterdir()) and not state: + raise ValueError("Choose an empty folder or an existing managed Ethoscore installation. Your source checkout is not an installation folder.") + destination.mkdir(parents=True, exist_ok=True) + lock = destination / ".install-lock" + try: + handle = os.open(lock, os.O_CREAT | os.O_EXCL | os.O_WRONLY) + except FileExistsError as error: + raise RuntimeError("Another installation is active. If it was interrupted, close Setup and remove .install-lock from this installation folder.") from error + stage = None + try: + os.close(handle) + versions = destination / "versions" + versions.mkdir(exist_ok=True) + stage = Path(tempfile.mkdtemp(prefix=".staging-", dir=versions)) + progress("Unpacking a fresh application version...") + metadata = extract_payload(archive, stage) + version = str(metadata.get("version", "development")) + final = versions / ("build-" + uuid.uuid4().hex[:12]) + stage.rename(final) + stage = None + user_data = destination / "user-data" + user_data.mkdir(exist_ok=True) + state = {"application": "Ethoscore", "schema": 1, "version": version, + "platform": platform_key(), "directory": str(final.relative_to(destination)), + "executable": metadata["executable"], "desktop": desktop, "menu": menu} + atomic_text(destination / MARKER, json.dumps(state, indent=2)) + _write_launcher(destination, state) + if shortcuts: + progress("Creating the selected launch shortcuts...") + try: + create_shortcuts(destination, state) + except Exception as error: + state["shortcut_warning"] = str(error) + progress(f"Application installed. Some shortcuts could not be created: {error}") + progress("Ethoscore is ready. Your videos and annotations are preserved.") + return state + finally: + # Only this invocation's newly created staging directory can be removed. + if stage and stage.parent == destination / "versions" and stage.name.startswith(".staging-"): + shutil.rmtree(stage) + with contextlib.suppress(FileNotFoundError): + lock.unlink() + with contextlib.suppress(OSError): + (destination / "versions").rmdir() + + +def installed_executable(destination: Path, state: dict) -> Path: + """Resolve the active executable only if it stays inside the managed versions.""" + executable = (destination / state["directory"] / state["executable"]).resolve() + if not executable.is_relative_to((destination / "versions").resolve()): + raise ValueError("The installation executable path is invalid.") + return executable + + +def _write_launcher(destination: Path, state: dict) -> None: + """Create a readable, stable launch entry beside the managed user-data folder.""" + executable = installed_executable(destination, state) + if os.name == "nt": + # VBScript launches the GUI without flashing a terminal window. + escaped = str(executable).replace('"', '""') + cwd = str(destination / "user-data").replace('"', '""') + atomic_text(destination / "Launch Ethoscore.vbs", + f'Set shell = CreateObject("WScript.Shell")\nshell.CurrentDirectory = "{cwd}"\nshell.Run Chr(34) & "{escaped}" & Chr(34), 1, False\n') + else: + launcher = destination / "Launch Ethoscore" + atomic_text(launcher, f"#!/bin/sh\ncd {shlex.quote(str(destination / 'user-data'))} || exit 1\nexec {shlex.quote(str(executable))} \"$@\"\n") + launcher.chmod(0o755) + if platform.system() == "Darwin": + app = next(parent for parent in executable.parents if parent.suffix == ".app") + link = destination / "Ethoscore.app" + if link.is_symlink(): + link.unlink() + elif link.exists(): + raise FileExistsError(f"Cannot replace an existing application at {link}") + link.symlink_to(app, target_is_directory=True) + + +def _windows_shortcut(path: Path, executable: Path, cwd: Path, arguments: str = "") -> None: + """Use the Windows shell COM API to write a .lnk without command interpolation.""" + path.parent.mkdir(parents=True, exist_ok=True) + script = "$s=(New-Object -ComObject WScript.Shell).CreateShortcut($env:ETHO_LINK);$s.TargetPath=$env:ETHO_TARGET;$s.Arguments=$env:ETHO_ARGUMENTS;$s.WorkingDirectory=$env:ETHO_CWD;$s.Description='Ethoscore behavioral video annotation';$s.Save()" + env = dict(os.environ, ETHO_LINK=str(path), ETHO_TARGET=str(executable), ETHO_CWD=str(cwd), ETHO_ARGUMENTS=arguments) + subprocess.run(["powershell.exe", "-NoProfile", "-NonInteractive", "-Command", script], env=env, check=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0), capture_output=True) + + +def _desktop_directory() -> Path: + """Find the actual Windows Known Folder or Linux XDG desktop directory.""" + if os.name == "nt": + import winreg + with winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Software\Microsoft\Windows\CurrentVersion\Explorer\User Shell Folders") as key: + return Path(os.path.expandvars(winreg.QueryValueEx(key, "Desktop")[0])) + if platform.system() == "Linux": + try: + value = subprocess.check_output(["xdg-user-dir", "DESKTOP"], text=True).strip() + if value: + return Path(value) + except (OSError, subprocess.CalledProcessError): + pass + return Path.home() / "Desktop" + + +def create_shortcuts(destination: Path, state: dict) -> None: + """Create only selected shortcuts using native platform conventions.""" + executable = installed_executable(destination, state) + desktop = _desktop_directory() if state["desktop"] else None + if os.name == "nt": + host = Path(os.environ.get("SystemRoot", "C:/Windows")) / "System32/wscript.exe" + arguments = '"' + str(destination / "Launch Ethoscore.vbs") + '"' + if desktop: + _windows_shortcut(desktop / "Ethoscore.lnk", host, destination / "user-data", arguments) + if state["menu"]: + menu = Path(os.environ["APPDATA"]) / "Microsoft/Windows/Start Menu/Programs" + _windows_shortcut(menu / "Ethoscore.lnk", host, destination / "user-data", arguments) + elif platform.system() == "Darwin": + app = destination / "Ethoscore.app" + for parent in ([desktop] if desktop else []) + ([Path.home() / "Applications"] if state["menu"] else []): + parent.mkdir(parents=True, exist_ok=True) + link = parent / "Ethoscore.app" + if link.is_symlink(): + link.unlink() + elif link.exists(): + raise FileExistsError(f"Cannot replace an existing application at {link}") + link.symlink_to(app, target_is_directory=True) + else: + # Desktop Entry quoting follows the freedesktop string escaping rules. + launch_path = str(destination / "Launch Ethoscore").replace("\\", "\\\\").replace('"', '\\"').replace("`", "\\`").replace("$", "\\$") + content = f'[Desktop Entry]\nType=Application\nName=Ethoscore\nComment=Behavioral video annotation\nExec="{launch_path}"\nTerminal=false\nCategories=Science;Education;\n' + for parent in ([desktop] if desktop else []) + ([Path(os.environ.get("XDG_DATA_HOME", Path.home() / ".local/share")) / "applications"] if state["menu"] else []): + parent.mkdir(parents=True, exist_ok=True) + target = parent / "ethoscore.desktop" + atomic_text(target, content) + target.chmod(0o755) + + +def launch(destination: Path) -> subprocess.Popen: + """Open the active application with user data as its writable working folder.""" + state = read_install(destination) + if not state: + raise RuntimeError("Ethoscore has not been installed in this folder.") + return subprocess.Popen([str(installed_executable(destination, state))], cwd=destination / "user-data", + start_new_session=os.name != "nt") diff --git a/installer/build.py b/installer/build.py new file mode 100644 index 0000000..dafd78b --- /dev/null +++ b/installer/build.py @@ -0,0 +1,83 @@ +"""Build a native application archive and self-contained graphical installer. + +Run in a Python 3.12 environment containing requirements.txt and PyInstaller. +Builds must run natively on each supported target OS and CPU architecture. +""" + +from __future__ import annotations + +import argparse +import hashlib +import json +import os +from pathlib import Path +import shutil +import subprocess +import sys +import tarfile + +from installer.backend import platform_key + + +def run_build(spec: Path, root: Path) -> None: + """Execute PyInstaller in a checked subprocess with deterministic output paths.""" + subprocess.run([sys.executable, "-m", "PyInstaller", "--noconfirm", "--clean", str(spec)], cwd=root, check=True) + + +def write_checksum(artifact: Path) -> None: + """Write the SHA-256 sidecar consumed by the graphical updater.""" + digest = hashlib.sha256() + with artifact.open("rb") as stream: + while chunk := stream.read(1024 * 1024): + digest.update(chunk) + artifact.with_name(artifact.name + ".sha256").write_text(f"{digest.hexdigest()} {artifact.name}\n", encoding="utf-8") + + +def build(version: str, skip_application: bool = False) -> None: + """Package application, metadata, checksum, and setup into release-ready files.""" + root = Path(__file__).resolve().parents[1] + if not skip_application: + run_build(root / "installer/application.spec", root) + payload = root / "build/installer_payload" + payload.mkdir(parents=True, exist_ok=True) + metadata = {"application": "Ethoscore", "version": version, "platform": platform_key(), + "executable": "Ethoscore.app/Contents/MacOS/Ethoscore" if sys.platform == "darwin" else + "Ethoscore/Ethoscore.exe" if sys.platform == "win32" else "Ethoscore/Ethoscore"} + metadata_path = payload / "payload.json" + metadata_path.write_text(json.dumps(metadata, indent=2), encoding="utf-8") + application = root / "dist" / ("Ethoscore.app" if sys.platform == "darwin" else "Ethoscore") + archive = payload / "application.tar.gz" + with tarfile.open(archive, "w:gz", compresslevel=3, dereference=False) as bundle: + bundle.add(metadata_path, arcname="payload.json") + bundle.add(application, arcname=application.name) + release = root / "dist/releases" + release.mkdir(parents=True, exist_ok=True) + target = release / f"Ethoscore-{platform_key()}.tar.gz" + shutil.copy2(archive, target) + write_checksum(target) + run_build(root / "installer/setup.spec", root) + if sys.platform == "darwin": + # ditto preserves the application bundle metadata in the distributed ZIP. + subprocess.run(["ditto", "-c", "-k", "--sequesterRsrc", "--keepParent", + str(root / "dist/Ethoscore-Setup.app"), + str(release / f"Ethoscore-Setup-{platform_key()}.zip")], check=True) + else: + suffix = ".exe" if sys.platform == "win32" else "" + target = release / f"Ethoscore-Setup-{platform_key()}{suffix}" + shutil.copy2(root / f"dist/Ethoscore-Setup{suffix}", target) + if os.name != "nt": + target.chmod(0o755) + print(f"Native installer and application package: {release}") + + +def main() -> None: + """Expose a short reproducible command for developers and release CI.""" + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--version", default="development", help="Version shown in Setup and installation manifests") + parser.add_argument("--skip-application", action="store_true", help="Reuse the existing native application build") + args = parser.parse_args() + build(args.version, args.skip_application) + + +if __name__ == "__main__": + main() diff --git a/installer/entry.py b/installer/entry.py new file mode 100644 index 0000000..f372555 --- /dev/null +++ b/installer/entry.py @@ -0,0 +1,6 @@ +"""PyInstaller entry point for native Ethoscore setup builds.""" + +from installer.gui import main + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/installer/gui.py b/installer/gui.py new file mode 100644 index 0000000..0dea065 --- /dev/null +++ b/installer/gui.py @@ -0,0 +1,285 @@ +"""A responsive, single-window installer shared by all supported platforms.""" + +from __future__ import annotations + +from pathlib import Path +import json +import os +import platform +import subprocess +import sys +import tempfile + +from PySide6.QtCore import QThread, Signal, Qt +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import (QApplication, QCheckBox, QComboBox, QFileDialog, + QFrame, QHBoxLayout, QLabel, QLineEdit, QMainWindow, + QMessageBox, QProgressBar, QPushButton, QTextEdit, + QVBoxLayout, QWidget) + +from installer.backend import (default_destination, download_release, install_payload, + latest_release, launch, read_install) + + +def bundled_payload() -> Path: + """Locate the archive embedded by the native setup build.""" + if getattr(sys, "frozen", False): + return Path(sys._MEIPASS) / "installer_payload/application.tar.gz" + return Path(__file__).resolve().parents[1] / "build/installer_payload/application.tar.gz" + + +class InstallWorker(QThread): + """Run network and disk work away from the Qt user interface.""" + + message = Signal(str) + succeeded = Signal(dict) + failed = Signal(str) + + def __init__(self, destination: Path, desktop: bool, menu: bool, online: bool): + """Capture the user's choices before the UI becomes temporarily locked.""" + super().__init__() + self.destination, self.desktop, self.menu, self.online = destination, desktop, menu, online + + def run(self): + """Download if requested, then atomically install and report the outcome.""" + try: + with tempfile.TemporaryDirectory(prefix="ethoscore-download-") as temporary: + if self.online: + self.message.emit("Looking for the latest official application build...") + archive = download_release(latest_release(), Path(temporary), self.message.emit) + else: + archive = bundled_payload() + if not archive.is_file(): + raise RuntimeError("This source installer has no bundled application. Choose Download latest, or build a native installer first.") + state = install_payload(archive, self.destination, self.desktop, self.menu, self.message.emit) + self.succeeded.emit(state) + except Exception as error: + self.failed.emit(str(error)) + + +class InstallerWindow(QMainWindow): + """Collect install choices, detect existing versions, and show clear progress.""" + + def __init__(self): + """Compose a polished installation form without administrator prompts.""" + super().__init__() + self.worker = None + self.setWindowTitle("Ethoscore Setup") + self.resize(760, 710) + self.setMinimumSize(650, 650) + self.setStyleSheet(""" + QMainWindow, QWidget { background: #111722; color: #edf2fa; font-size: 14px; } + QLabel#eyebrow { color: #7bdac9; font-size: 12px; font-weight: 700; } + QLabel#title { font-size: 38px; font-weight: 700; } + QLabel#subtitle { color: #a7b7cc; font-size: 15px; } + QLabel#section { font-weight: 650; font-size: 14px; } + QLabel#state { background: #1b2c38; border: 1px solid #2b4958; border-radius: 9px; padding: 13px; color: #a5e7d8; } + QLineEdit, QComboBox { background: #1c2635; border: 1px solid #34465b; border-radius: 7px; padding: 10px; } + QComboBox QAbstractItemView { background: #1c2635; } + QPushButton { border: 1px solid #3a5068; border-radius: 7px; padding: 11px 18px; background: #233249; font-weight: 600; } + QPushButton:hover { background: #2c425c; } + QPushButton#primary { background: #55d8bd; color: #08271f; border: none; min-width: 155px; } + QPushButton#primary:hover { background: #80ead4; } + QPushButton:disabled { background: #26313d; color: #647488; } + QCheckBox { spacing: 10px; padding: 4px 0; } + QCheckBox::indicator { width: 18px; height: 18px; } + QTextEdit { border: none; background: #0c121c; border-radius: 8px; color: #a7b7cc; font-size: 12px; padding: 7px; } + QProgressBar { border: none; border-radius: 3px; background: #233249; height: 6px; text-align: center; } + QProgressBar::chunk { background: #55d8bd; border-radius: 3px; } + """) + content = QWidget() + self.setCentralWidget(content) + layout = QVBoxLayout(content) + layout.setContentsMargins(34, 30, 34, 25) + layout.setSpacing(14) + eyebrow = QLabel("BEHAVIORAL ANNOTATION / SETUP") + eyebrow.setObjectName("eyebrow") + layout.addWidget(eyebrow) + title = QLabel("Ethoscore Setup") + title.setObjectName("title") + layout.addWidget(title) + subtitle = QLabel("Install Ethoscore. Return here anytime to update your application.") + subtitle.setObjectName("subtitle") + subtitle.setWordWrap(True) + layout.addWidget(subtitle) + self.status = QLabel() + self.status.setObjectName("state") + self.status.setWordWrap(True) + layout.addWidget(self.status) + section = QLabel("01 Installation folder") + section.setObjectName("section") + layout.addWidget(section) + path_row = QHBoxLayout() + self.destination = QLineEdit(str(default_destination())) + self.destination.textChanged.connect(self.refresh_state) + self.browse = QPushButton("Browse") + self.browse.clicked.connect(self.choose_destination) + path_row.addWidget(self.destination, 1) + path_row.addWidget(self.browse) + layout.addLayout(path_row) + section = QLabel("02 Application version") + section.setObjectName("section") + layout.addWidget(section) + self.source = QComboBox() + if bundled_payload().is_file(): + self.source.addItem("Install the version bundled with this Setup (offline)", False) + self.source.addItem("Download the latest official build (internet required)", True) + layout.addWidget(self.source) + shortcut_row = QHBoxLayout() + self.desktop = QCheckBox("Desktop shortcut") + self.desktop.setChecked(True) + self.menu = QCheckBox("Start menu shortcut" if sys.platform == "win32" else "Applications shortcut") + self.menu.setChecked(True) + shortcut_row.addWidget(self.desktop) + shortcut_row.addWidget(self.menu) + shortcut_row.addStretch() + layout.addLayout(shortcut_row) + note = QLabel("Installs for your account. Updates keep your videos, annotations, settings, and earlier application versions.") + note.setObjectName("subtitle") + note.setWordWrap(True) + layout.addWidget(note) + self.progress = QProgressBar() + self.progress.setRange(0, 100) + self.progress.setValue(0) + self.progress.setTextVisible(False) + layout.addWidget(self.progress) + self.log = QTextEdit() + self.log.setReadOnly(True) + self.log.setMaximumHeight(95) + self.log.setPlaceholderText("Ready when you are.") + layout.addWidget(self.log, 1) + buttons = QHBoxLayout() + self.open_button = QPushButton("Launch Ethoscore") + self.open_button.clicked.connect(self.launch_application) + self.close_button = QPushButton("Close") + self.close_button.clicked.connect(self.close) + self.install_button = QPushButton("Install Ethoscore") + self.install_button.setObjectName("primary") + self.install_button.clicked.connect(self.begin_install) + buttons.addWidget(self.open_button) + buttons.addStretch() + buttons.addWidget(self.close_button) + buttons.addWidget(self.install_button) + layout.addLayout(buttons) + self.refresh_state() + + def refresh_state(self): + """Reflect the selected folder's installed version and remembered choices.""" + try: + state = read_install(Path(self.destination.text()).expanduser()) + self.status.setText(f"Installed: {state['version']}. Ready to update or reinstall." if state else "A fresh start. Ethoscore is ready to install for your account.") + self.install_button.setText("Update / Reinstall" if state else "Install Ethoscore") + self.open_button.setEnabled(bool(state)) + if state: + self.desktop.setChecked(state.get("desktop", True)) + self.menu.setChecked(state.get("menu", True)) + except (OSError, ValueError, KeyError) as error: + self.status.setText(f"Cannot read this installation: {error}") + self.open_button.setEnabled(False) + + def choose_destination(self): + """Let the user choose a parent or an existing managed install folder.""" + folder = QFileDialog.getExistingDirectory(self, "Choose installation folder", self.destination.text()) + if folder: + chosen = Path(folder) + if not (chosen / ".ethoscore-install.json").exists() and any(chosen.iterdir()): + chosen /= "Ethoscore" + self.destination.setText(str(chosen)) + + def begin_install(self): + """Freeze user choices and start installation without blocking repainting.""" + destination = Path(self.destination.text()).expanduser() + if not self.destination.text().strip() or not destination.is_absolute(): + QMessageBox.warning(self, "Choose a folder", "Choose an absolute installation folder first.") + return + self.set_busy(True) + self.log.clear() + self.worker = InstallWorker(destination, self.desktop.isChecked(), self.menu.isChecked(), bool(self.source.currentData())) + self.worker.message.connect(self.log.append) + self.worker.succeeded.connect(self.install_finished) + self.worker.failed.connect(self.install_failed) + self.worker.finished.connect(lambda: self.set_busy(False)) + self.worker.start() + + def set_busy(self, busy: bool): + """Keep the installation thread alive until all disk changes are complete.""" + for widget in (self.destination, self.browse, self.source, self.desktop, self.menu, self.install_button, self.close_button): + widget.setEnabled(not busy) + try: + installed = read_install(Path(self.destination.text())) is not None + except (OSError, ValueError): + installed = False + self.open_button.setEnabled(not busy and installed) + self.progress.setRange(0, 0 if busy else 100) + if not busy: + self.progress.setValue(100) + + def install_finished(self, state: dict): + """Show the exact active version and make the launch action available.""" + self.refresh_state() + self.status.setText(f"Ready to launch. Ethoscore {state['version']} is installed.") + self.open_button.setEnabled(True) + + def install_failed(self, message: str): + """Keep error details visible while allowing a corrected retry.""" + self.log.append("Installation stopped: " + message) + QMessageBox.warning(self, "Installation could not finish", message) + + def launch_application(self): + """Start the active installed version while leaving Setup open for review.""" + try: + launch(Path(self.destination.text()).expanduser()) + except Exception as error: + QMessageBox.warning(self, "Could not launch Ethoscore", str(error)) + + def closeEvent(self, event): + """Prevent closing halfway through an installation transaction.""" + if self.worker and self.worker.isRunning(): + event.ignore() + else: + event.accept() + + +def main() -> int: + """Start the graphical installer from either source or a packaged executable.""" + app = QApplication(sys.argv) + app.setApplicationName("Ethoscore Setup") + if sys.platform == "win32" and not QFontDatabase.families(): + font_dir = Path(os.environ.get("SystemRoot", "C:/Windows")) / "Fonts" + for filename in ("segoeui.ttf", "segoeuib.ttf"): + QFontDatabase.addApplicationFont(str(font_dir / filename)) + app.setFont(QFont("Segoe UI" if platform.system() == "Windows" else "Sans Serif", 10)) + window = InstallerWindow() + window.show() + if len(sys.argv) == 3 and sys.argv[1] == "--smoke-test": + # Native CI verifies the actual embedded payload and two transactions. + report = Path(sys.argv[2]).resolve() + report.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ethoscore-setup-smoke-") as temporary: + destination = Path(temporary) / "installed" + window.destination.setText(str(destination)) + window.desktop.setChecked(False) + window.menu.setChecked(False) + app.processEvents() + first = install_payload(bundled_payload(), destination, False, False, shortcuts=False) + sentinel = destination / "user-data/annotations-preserved.csv" + sentinel.write_text("frame,label\n1,test\n", encoding="utf-8") + second = install_payload(bundled_payload(), destination, False, False, shortcuts=False) + if first["directory"] == second["directory"] or sentinel.read_text(encoding="utf-8") != "frame,label\n1,test\n": + raise RuntimeError("The native installer failed its update/data preservation check.") + from installer.backend import installed_executable + app_report = report.with_name(report.stem + "-application.json") + subprocess.run([str(installed_executable(destination, second)), "--smoke-test", str(app_report)], + timeout=120, check=True) + if json.loads(app_report.read_text(encoding="utf-8"))["status"] != "passed": + raise RuntimeError("The installed native application failed its startup check.") + window.refresh_state() + window.log.append("Native install, update, data preservation, and application startup passed.") + app.processEvents() + window.grab().save(str(report.with_suffix(".png"))) + report.write_text(json.dumps({"status": "passed", "version": second["version"], + "checks": ["native setup GUI", "bundled offline install", "repeat install/update", + "annotation preservation", "installed application startup", "bundled FFmpeg and SVG assets"]}, indent=2), encoding="utf-8") + window.close() + return 0 + return app.exec() diff --git a/installer/native_smoke.py b/installer/native_smoke.py new file mode 100644 index 0000000..8ca3e29 --- /dev/null +++ b/installer/native_smoke.py @@ -0,0 +1,53 @@ +"""Launch the native setup smoke test after building, on any supported OS.""" + +from pathlib import Path +import json +import io +import os +import subprocess +import sys +import tarfile +import tempfile + + +def verify_bundled_race_guards(setup: Path) -> None: + """Inspect the executable inside Setup itself to reject a stale app snapshot.""" + from PyInstaller.archive.readers import CArchiveReader + + bootloader = CArchiveReader(str(setup)) + entry = next(name for name in bootloader.toc if name.replace("\\", "/") == "installer_payload/application.tar.gz") + with tarfile.open(fileobj=io.BytesIO(bootloader.extract(entry)), mode="r:gz") as payload: + metadata = json.load(payload.extractfile("payload.json")) + with tempfile.TemporaryDirectory(prefix="ethoscore-bytecode-check-") as temporary: + binary = Path(temporary) / "application.bin" + with binary.open("wb") as target: + import shutil + shutil.copyfileobj(payload.extractfile(metadata["executable"]), target) + modules = CArchiveReader(str(binary)).open_embedded_archive("PYZ.pyz") + module = modules.extract("ethoscore") + annotation_class = next(code for code in module.co_consts if getattr(code, "co_name", None) == "VideoAnnotator") + load = next(code for code in annotation_class.co_consts if getattr(code, "co_name", None) == "load_video_by_path") + if "_loading_video" not in load.co_names or "previous_arena_context" not in load.co_varnames: + raise RuntimeError("Setup contains an outdated application without failed-load/autosave guards.") + + +def main() -> None: + """Require the actual installed application and setup transaction reports.""" + root = Path(__file__).resolve().parents[1] + executable = root / "dist" / ("Ethoscore-Setup.app/Contents/MacOS/Ethoscore-Setup" if sys.platform == "darwin" else + "Ethoscore-Setup.exe" if sys.platform == "win32" else "Ethoscore-Setup") + report = root / "tests/artifacts/native-installer.json" + report.parent.mkdir(parents=True, exist_ok=True) + verify_bundled_race_guards(executable) + env = dict(os.environ, QT_QPA_PLATFORM="offscreen", SDL_AUDIODRIVER="dummy") + subprocess.run([str(executable), "--smoke-test", str(report)], check=True, timeout=300, env=env) + outcome = json.loads(report.read_text(encoding="utf-8")) + if outcome.get("status") != "passed": + raise RuntimeError("The native installer did not pass its smoke test.") + outcome["bundled_failed_load_race_guards"] = True + report.write_text(json.dumps(outcome, indent=2), encoding="utf-8") + print(json.dumps(outcome, indent=2)) + + +if __name__ == "__main__": + main() diff --git a/installer/setup.spec b/installer/setup.spec new file mode 100644 index 0000000..c2f1441 --- /dev/null +++ b/installer/setup.spec @@ -0,0 +1,16 @@ +# -*- mode: python ; coding: utf-8 -*- +"""Self-contained graphical setup with the matching native application payload.""" + +from pathlib import Path +import sys + +root = Path(SPECPATH).parent +a = Analysis([str(root / 'installer/entry.py')], pathex=[str(root)], + datas=[(str(root / 'build/installer_payload/application.tar.gz'), 'installer_payload')], + hiddenimports=[], excludes=['tkinter', 'matplotlib', 'numpy', 'pandas', 'cv2', 'pygame', + 'PySide6.QtWebEngineCore', 'PySide6.QtQml'], noarchive=False) +pyz = PYZ(a.pure) +exe = EXE(pyz, a.scripts, a.binaries, a.datas, [], name='Ethoscore-Setup', + console=False, debug=False, strip=False, upx=False) +if sys.platform == 'darwin': + app = BUNDLE(exe, name='Ethoscore-Setup.app', bundle_identifier='org.bellonelab.ethoscore.setup') diff --git a/installer/test_backend.py b/installer/test_backend.py new file mode 100644 index 0000000..2e4c851 --- /dev/null +++ b/installer/test_backend.py @@ -0,0 +1,164 @@ +"""Installer contract tests: updates preserve data and archives cannot escape.""" + +from __future__ import annotations + +import io +import hashlib +import json +import os +import sys +from pathlib import Path +import tarfile +import tempfile +import unittest +from unittest.mock import patch + +from installer import backend + + +class InstallationTests(unittest.TestCase): + """Exercise fresh install, update, failure recovery, locking, and path security.""" + + def setUp(self): + """Use an isolated installation tree, never the user's actual app or files.""" + self.temporary = tempfile.TemporaryDirectory(prefix="ethoscore-installer-test-") + self.addCleanup(self.temporary.cleanup) + self.root = Path(self.temporary.name) + self.destination = self.root / "installed application" + + def payload(self, version="one", extra=None, platform=None): + """Create a tiny application-shaped tarball with optional hostile entries.""" + path = self.root / f"payload-{version}.tar.gz" + executable = "Ethoscore.app/Contents/MacOS/Ethoscore" if sys.platform == "darwin" else "app/Ethoscore.exe" + metadata = {"application": "Ethoscore", "version": version, + "platform": platform or backend.platform_key(), "executable": executable} + with tarfile.open(path, "w:gz") as archive: + for name, data in (("payload.json", json.dumps(metadata).encode()), (executable, version.encode())): + member = tarfile.TarInfo(name) + member.size, member.mode = len(data), 0o755 + archive.addfile(member, io.BytesIO(data)) + if extra: + archive.addfile(extra, io.BytesIO(b"bad") if extra.isfile() else None) + return path + + def install(self, archive, **kwargs): + """Disable real shortcuts so test runs cannot modify OS integration.""" + return backend.install_payload(archive, self.destination, shortcuts=False, **kwargs) + + def test_install_update_preserves_data_and_prior_version(self): + """An update changes active code while every user file and old build remain.""" + first = self.install(self.payload()) + old_executable = backend.installed_executable(self.destination, first) + annotations = self.destination / "user-data/annotations.csv" + annotations.write_text("frame,label\n1,nose-to-nose\n", encoding="utf-8") + original = annotations.read_bytes() + second = self.install(self.payload("two"), desktop=False, menu=True) + self.assertEqual(backend.read_install(self.destination)["version"], "two") + self.assertEqual(annotations.read_bytes(), original) + self.assertEqual(old_executable.read_bytes(), b"one") + self.assertEqual(backend.installed_executable(self.destination, second).read_bytes(), b"two") + self.assertNotEqual(first["directory"], second["directory"]) + self.assertFalse(second["desktop"]) + + def test_bad_update_leaves_previous_active_manifest(self): + """A failed extraction does not alter the active application or leave a lock.""" + self.install(self.payload()) + before = (self.destination / backend.MARKER).read_bytes() + with self.assertRaises(ValueError): + self.install(self.payload("wrong-platform", platform="unsupported")) + self.assertEqual((self.destination / backend.MARKER).read_bytes(), before) + self.assertFalse((self.destination / ".install-lock").exists()) + self.assertEqual(len(list((self.destination / "versions").iterdir())), 1) + + def test_stable_launcher_tracks_update_without_shortcut_recreation(self): + """A previously created shortcut follows the rewritten stable launcher.""" + first = self.install(self.payload()) + launcher = self.destination / ("Launch Ethoscore.vbs" if os.name == "nt" else "Launch Ethoscore") + self.assertIn(str(backend.installed_executable(self.destination, first)), launcher.read_text(encoding="utf-8")) + second = self.install(self.payload("two"), desktop=False, menu=False) + self.assertIn(str(backend.installed_executable(self.destination, second)), launcher.read_text(encoding="utf-8")) + self.assertNotIn(first["directory"].split("/")[-1], launcher.read_text(encoding="utf-8")) + + def test_failed_fresh_install_can_retry(self): + """Failed new installs leave a destination that can immediately be retried.""" + with self.assertRaises(ValueError): + self.install(self.payload("wrong-platform", platform="unsupported")) + self.install(self.payload()) + self.assertEqual(backend.read_install(self.destination)["version"], "one") + + def test_unrelated_nonempty_directory_is_rejected(self): + """Installing into a checkout or a data folder cannot overwrite its files.""" + self.destination.mkdir() + sentinel = self.destination / "ethoscore.py" + sentinel.write_text("user source", encoding="utf-8") + with self.assertRaises(ValueError): + self.install(self.payload()) + self.assertEqual(sentinel.read_text(encoding="utf-8"), "user source") + + def test_archive_traversal_and_external_symlink_are_rejected(self): + """Both ordinary traversal and link traversal fail without outside writes.""" + for kind in ("path", "symlink"): + with self.subTest(kind=kind): + member = tarfile.TarInfo("../escaped" if kind == "path" else "app/external") + if kind == "symlink": + member.type, member.linkname = tarfile.SYMTYPE, "../../escaped" + else: + member.size = 3 + with self.assertRaises((ValueError, tarfile.FilterError)): + self.install(self.payload(kind, member)) + self.assertFalse((self.root / "escaped").exists()) + + def test_concurrent_install_lock_is_respected(self): + """A second installer cannot write while another operation owns the lock.""" + self.install(self.payload()) + (self.destination / ".install-lock").touch() + with self.assertRaises(RuntimeError): + self.install(self.payload("two")) + self.assertEqual(backend.read_install(self.destination)["version"], "one") + + def test_untrusted_release_download_url_is_rejected(self): + """Network release metadata cannot direct downloads to arbitrary servers.""" + for url in ("http://github.com/BelloneLab/ethoscore/releases/download/v1/app", "https://evil.example/file", "https://github.com/other/repo/releases/download/v1/app"): + with self.subTest(url=url), self.assertRaises(ValueError): + backend.download(url, self.root / "download") + + def test_download_checksum_detects_corruption(self): + """A corrupt release package never reaches the installation transaction.""" + content = b"official application payload" + release = {"name": "application.tar.gz", "url": "archive", "checksum_url": "checksum"} + + def fake_download(url, target, progress): + """Supply a deterministic local stand-in for each network response.""" + target.write_bytes((hashlib.sha256(content).hexdigest() + " application.tar.gz\n").encode() if url == "checksum" else b"corrupted payload") + + with patch.object(backend, "download", side_effect=fake_download), self.assertRaisesRegex(ValueError, "SHA-256"): + backend.download_release(release, self.root, lambda _: None) + + def test_online_update_prefers_continuous_release(self): + """Main builds use the rolling release even when a stable tag also exists.""" + name = f"Ethoscore-{backend.platform_key()}.tar.gz" + response = {"tag_name": "latest", "assets": [ + {"name": name, "browser_download_url": "https://github.com/BelloneLab/ethoscore/releases/download/latest/" + name}, + {"name": name + ".sha256", "browser_download_url": "https://github.com/BelloneLab/ethoscore/releases/download/latest/" + name + ".sha256"}]} + with patch.object(backend.urllib.request, "urlopen", return_value=io.BytesIO(json.dumps(response).encode())) as request: + self.assertEqual(backend.latest_release()["version"], "latest") + self.assertEqual(request.call_count, 1) + self.assertTrue(request.call_args.args[0].full_url.endswith("/releases/tags/latest")) + + def test_shortcut_failure_keeps_successful_application_install(self): + """OS shortcut errors report a warning without invalidating installed code.""" + with patch.object(backend, "create_shortcuts", side_effect=OSError("denied")): + state = backend.install_payload(self.payload(), self.destination) + self.assertIn("denied", state["shortcut_warning"]) + self.assertEqual(backend.read_install(self.destination)["version"], "one") + + def test_installed_executable_rejects_modified_outside_path(self): + """A tampered control file cannot make the launcher execute outside versions.""" + state = self.install(self.payload()) + state["executable"] = "../../../../foreign.exe" + with self.assertRaises(ValueError): + backend.installed_executable(self.destination, state) + + +if __name__ == "__main__": + unittest.main() diff --git a/requirements.txt b/requirements.txt index 911c1aa..5caf218 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,5 +1,6 @@ PySide6>=6.4.0 -opencv-python>=4.8.0 +opencv-python-headless>=4.8.0 pandas>=1.5.0 numpy>=1.21.0 pygame>=2.0.0 +imageio-ffmpeg>=0.5.1 diff --git a/tests/test_arena_backend.py b/tests/test_arena_backend.py new file mode 100644 index 0000000..a93b032 --- /dev/null +++ b/tests/test_arena_backend.py @@ -0,0 +1,192 @@ +"""End-to-end arena integrity checks using disposable generated movies. + +Run with ``python -m unittest discover -s tests -p test_arena_backend.py -v``. +No recording, metadata, or annotation from a user is opened or modified. +""" + +from pathlib import Path +import subprocess +import tempfile +import csv +import time +import unittest + +import cv2 +import numpy as np + +from annotator_libs.arena_model import Arena, ArenaProject, detect_arenas, grid_arenas, source_identity +from annotator_libs.arena_export import ExportCancelled, export_arenas, ffmpeg_executable, probe_video + + +class ArenaTests(unittest.TestCase): + """Exercise coordinate, manifest, cancellation, and frame integrity rules.""" + + def setUp(self): + """Give each check a private source and export directory.""" + self.temporary = tempfile.TemporaryDirectory(prefix="ethoscore-arena-test-") + self.root = Path(self.temporary.name) + self.source = self.root / "source.mkv" + self.frames = [] + for frame_index in range(17): + image = np.zeros((72, 96, 3), np.uint8) + image[:] = (30, 100, 170) + image[:, :48] = (150, 50, 30) + image[frame_index:frame_index + 5, :] = (210, 210, 210) + self.frames.append(image) + flags = subprocess.CREATE_NO_WINDOW if __import__("os").name == "nt" else 0 + result = subprocess.run([ffmpeg_executable(), "-v", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", + "-s", "96x72", "-r", "30000/1001", "-i", "pipe:0", "-c:v", "ffv1", str(self.source)], + input=b"".join(frame.tobytes() for frame in self.frames), capture_output=True, creationflags=flags) + if result.returncode: + raise RuntimeError(result.stderr.decode()) + + def tearDown(self): + """Remove only the disposable test directory.""" + self.temporary.cleanup() + + def test_detection_and_no_evidence(self): + """A long cross supports four crops; blank scenes do not.""" + image = np.full((320, 400, 3), 160, np.uint8) + image[:, 198:202] = 20 + image[158:162] = 20 + self.assertEqual(len(detect_arenas(image)), 4) + self.assertEqual(detect_arenas(np.full_like(image, 100)), []) + # A wide outer illumination step must not create an extra column. + image[:, :75] = 80 + self.assertEqual(len(detect_arenas(image)), 4) + + def test_manifest_and_traversal(self): + """Round-trip all progress while rejecting unsafe identifiers.""" + arena = Arena("arena_1", "Mouse A", 1, 2, 31, 35, "ongoing", 11) + project = ArenaProject.create(self.source, [arena]) + folder = project.save(self.root / "project") + self.assertEqual(ArenaProject.load(folder).arenas[0], arena) + for unsafe in ("../source", "/source", "CON", "a/b", "a\\b", ".."): + with self.assertRaises(ValueError): + Arena(unsafe, "Bad", 0, 0, 10, 10) + with self.assertRaises(ValueError): + ArenaProject.create(self.source, [Arena("a", "A", 80, 0, 30, 20)]) + self.source.touch() + with self.assertRaises(ValueError): + ArenaProject.load(folder) + + def test_export_odd_bounds_exact_frames_and_annotation_safety(self): + """Odd crops keep every frame and do not touch pre-existing CSVs.""" + arenas = [Arena("left", "Left", 1, 3, 35, 33), Arena("right", "Right", 49, 2, 45, 49)] + folder = self.root / "export" + (folder / "left").mkdir(parents=True) + annotations = folder / "left" / "video.csv" + annotations.write_text("original annotations\n") + before = source_identity(self.source) + updates = [] + outputs = export_arenas(self.source, arenas, folder, updates.append, lossless=True) + self.assertEqual(source_identity(self.source), before) + self.assertEqual(annotations.read_text(), "original annotations\n") + self.assertEqual(updates[-1], 1.0) + for arena in arenas: + info = probe_video(outputs[arena.id], exact_count=True) + self.assertEqual(info["frame_count"], len(self.frames)) + self.assertEqual((info["fps_num"], info["fps_den"]), (30000, 1001)) + capture = cv2.VideoCapture(str(outputs[arena.id])) + for original in self.frames: + ok, measured = capture.read() + self.assertTrue(ok) + expected = original[arena.y:arena.y + arena.height, arena.x:arena.x + arena.width] + self.assertEqual(measured.shape, expected.shape) + # H.264 is lossless in YUV; RGB conversion can differ by 2 levels. + self.assertLessEqual(np.abs(measured.astype(int) - expected.astype(int)).max(), 3) + self.assertFalse(capture.read()[0]) + capture.release() + with self.assertRaises(FileExistsError): + export_arenas(self.source, arenas, folder) + self.assertTrue(all(path.exists() for path in outputs.values())) + + def test_cancel_preserves_existing_files(self): + """Cancellation never publishes partial files or deletes annotations.""" + folder = self.root / "cancel" + arena = Arena("a", "A", 0, 0, 48, 72) + arena.folder(folder).mkdir(parents=True) + arena.annotations_path(folder).write_text("keep\n") + cancel_requested = [False] + + def progress(value): + """Request cancellation once FFmpeg has started writing output.""" + cancel_requested[0] = value > 0 + + with self.assertRaises(ExportCancelled): + export_arenas(self.source, [arena], folder, progress, lambda: cancel_requested[0]) + self.assertFalse(arena.video_path(folder).exists()) + self.assertEqual(arena.annotations_path(folder).read_text(), "keep\n") + self.assertFalse(list(folder.glob(".arena-export-*"))) + + def test_variable_frame_rate(self): + """Irregular timestamps must not introduce duplicate or missing frames.""" + vfr = self.root / "variable.mkv" + flags = subprocess.CREATE_NO_WINDOW if __import__("os").name == "nt" else 0 + result = subprocess.run([ffmpeg_executable(), "-v", "error", "-i", str(self.source), "-vf", + "setpts='if(lt(N,8),N/(30*TB),(8+(N-8)*2)/(30*TB))'", + "-fps_mode", "vfr", "-c:v", "ffv1", str(vfr)], capture_output=True, creationflags=flags) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + source_info = probe_video(vfr, exact_count=True) + paths = export_arenas(vfr, [Arena("a", "A", 1, 1, 45, 61)], self.root / "vfr_export") + output_info = probe_video(paths["a"], exact_count=True) + self.assertEqual(source_info["frame_count"], output_info["frame_count"]) + self.assertEqual(source_info["frame_count"], 17) + + +def benchmark_exports(output_path, repeats=3): + """Compare validated shared-decoder exports against independent exports. + + This is an engineering benchmark, not an inferential biological analysis. + CSV rows retain paired raw timings, exact frame counts, FPS, and geometry so + another machine can reproduce the workload and compare measured speedups. + The short synthetic clip includes noise and motion rather than flat frames. + """ + output_path = Path(output_path) + output_path.parent.mkdir(parents=True, exist_ok=True) + with tempfile.TemporaryDirectory(prefix="ethoscore-arena-benchmark-") as temporary: + root = Path(temporary) + source = root / "benchmark.mkv" + flags = subprocess.CREATE_NO_WINDOW if __import__("os").name == "nt" else 0 + generated = subprocess.run([ffmpeg_executable(), "-v", "error", "-f", "lavfi", "-i", + "testsrc2=size=640x480:rate=30000/1001", "-frames:v", "180", + "-c:v", "libx264", "-preset", "fast", str(source)], + capture_output=True, creationflags=flags) + if generated.returncode: + raise RuntimeError(generated.stderr.decode()) + arenas = grid_arenas(640, 480) + rows = [] + for repeat in range(repeats): + # Alternate order to reduce systematic cache/order bias. + methods = ("shared_decode", "sequential") if repeat % 2 == 0 else ("sequential", "shared_decode") + for method in methods: + start = time.perf_counter() + if method == "shared_decode": + outputs = export_arenas(source, arenas, root / f"shared_{repeat}") + else: + outputs = {} + for arena in arenas: + outputs.update(export_arenas(source, [arena], root / f"sequential_{repeat}_{arena.id}")) + elapsed = time.perf_counter() - start + counts = [probe_video(path, exact_count=True)["frame_count"] for path in outputs.values()] + if counts != [180] * 4: + raise AssertionError(f"Benchmark produced incorrect frame counts: {counts}") + rows.append({"repeat": repeat + 1, "method": method, "elapsed_seconds": elapsed, + "source_frames": 180, "source_width": 640, "source_height": 480, + "fps_num": 30000, "fps_den": 1001, "arenas": 4, + "output_frames_each": 180, "validated": True}) + with output_path.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=list(rows[0])) + writer.writeheader() + writer.writerows(rows) + return rows + + +if __name__ == "__main__": + import sys + if len(sys.argv) > 1 and sys.argv[1] == "--benchmark": + benchmark_path = Path(__file__).parent / "artifacts" / "arena_export_benchmark.csv" + print(benchmark_exports(benchmark_path)) + print(f"Saved benchmark: {benchmark_path}") + else: + unittest.main() diff --git a/tests/test_arena_workflow.py b/tests/test_arena_workflow.py new file mode 100644 index 0000000..b145dae --- /dev/null +++ b/tests/test_arena_workflow.py @@ -0,0 +1,225 @@ +"""Exercise arena isolation through the real Qt editor using synthetic videos. + +Tests run offscreen and redirect every QSettings store to a temporary directory. +They never close or change an existing user session or open real recordings. +""" + +import os +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") + +from pathlib import Path +import subprocess +import tempfile +import unittest +from unittest.mock import patch + +import cv2 +import numpy as np +import pandas as pd +from PySide6.QtCore import QSettings, Qt, QPoint +from PySide6.QtTest import QTest +from PySide6.QtWidgets import QApplication, QMessageBox + +import ethoscore +from annotator_libs.arena_export import export_arenas, ffmpeg_executable +from annotator_libs.arena_model import Arena, ArenaProject, default_project_dir +from annotator_libs.arena_ui import ArenaEditor + + +class ArenaWorkflowTests(unittest.TestCase): + """Verify real crop selection, isolated CSVs, progress, and manual drawing.""" + + @classmethod + def setUpClass(cls): + """Keep a single QApplication alive for all widgets in this module.""" + cls.app = QApplication.instance() or QApplication([]) + + def setUp(self): + """Create two distinct crop videos and private application settings.""" + self.temporary = tempfile.TemporaryDirectory(prefix="ethoscore-workflow-test-") + self.root = Path(self.temporary.name) + self.source = self.root / "two_arenas.mkv" + frames = [] + for index in range(18): + frame = np.zeros((72, 96, 3), np.uint8) + frame[:, :48] = (30, 90, 160) + frame[:, 48:] = (150, 60, 25) + frame[index * 3:index * 3 + 3] = (index * 10, 220, 220) + frames.append(frame) + result = subprocess.run([ + ffmpeg_executable(), "-v", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", + "-s", "96x72", "-r", "30", "-i", "pipe:0", "-c:v", "ffv1", str(self.source), + ], input=b"".join(frame.tobytes() for frame in frames), capture_output=True, + creationflags=getattr(subprocess, "CREATE_NO_WINDOW", 0)) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.arenas = [Arena("left", "Arena 1", 0, 0, 48, 72), Arena("right", "Arena 2", 48, 0, 48, 72)] + self.project_dir = default_project_dir(self.source) + export_arenas(self.source, self.arenas, self.project_dir, lossless=True) + self.settings_patch = patch.object(ethoscore, "QSettings", self.private_settings) + self.settings_patch.start() + self.window = ethoscore.VideoAnnotator() + self.window.auto_save_timer.stop() + self.window.gamepad_timer.stop() + self.window.show_statistics_popup = False + self.window.resize(1240, 900) + + def private_settings(self, organization, application): + """Replace registry-backed settings with disposable INI files.""" + return QSettings(str(self.root / f"{organization}_{application}.ini"), QSettings.IniFormat) + + def tearDown(self): + """Save only synthetic annotations, then stop workers before deleting files.""" + self.window.close() + self.app.processEvents() + self.settings_patch.stop() + self.temporary.cleanup() + + def test_switch_isolates_annotations_and_restores_frame(self): + """Each selected crop fills the view and owns its labels, undo, and frame.""" + self.assertTrue(self.window.load_video_by_path(str(self.source))) + self.assertEqual(self.window.active_arena_id, "left") + self.assertEqual(self.window.video_path, str(self.arenas[0].video_path(self.project_dir))) + self.assertEqual(self.window.video_player.total_frames, 18) + self.assertEqual(self.window.video_player.original_pixmap.width(), 48) + self.window.video_player.goto_frame(3) + self.window.push_undo_state() + self.window.annotations[3] = ["rearing"] + self.assertEqual(self.window.active_arena().status, "ongoing") + self.assertTrue(self.window.switch_arena("right")) + self.assertEqual(self.window.annotations, {}) + self.assertEqual(self.window.undo_stack, []) + self.assertFalse(self.window.video_player.range_labeling_active) + left_csv = pd.read_csv(self.arenas[0].annotations_path(self.project_dir)) + self.assertEqual(len(left_csv), 18) + self.assertEqual(left_csv.loc[3, "Frames"], 4) + self.assertEqual(left_csv.loc[3, "rearing"], 1) + self.window.video_player.goto_frame(8) + self.window.push_undo_state() + self.window.annotations[8] = ["passive"] + self.assertTrue(self.window.switch_arena("left")) + self.assertEqual(self.window.annotations, {3: ["rearing"]}) + self.assertEqual(self.window.video_player.current_frame, 3) + self.assertEqual(self.window.last_video_path, str(self.source)) + right_csv = pd.read_csv(self.arenas[1].annotations_path(self.project_dir)) + self.assertEqual(right_csv.loc[8, "passive"], 1) + self.assertEqual(right_csv["rearing"].sum(), 0) + self.assertFalse(self.source.with_suffix(".csv").exists()) + + def test_completion_persists_and_edit_reopens(self): + """Explicit completion works even without labels; a later edit reopens work.""" + self.window.load_video_by_path(str(self.source)) + status = self.window.arena_status_selector + status.setCurrentIndex(status.findData("completed")) + self.assertEqual(ArenaProject.load(self.project_dir).arenas[0].status, "completed") + self.window.push_undo_state() + self.assertEqual(self.window.active_arena().status, "ongoing") + self.window.video_player.range_labeling_active = {"rearing": True} + self.window.video_player.range_labeling_start = {"rearing": 1} + self.window.video_player.current_frame = 5 + self.window.video_player.include_last_frame_in_range = True + self.window.switch_arena("right") + self.assertFalse(self.window.video_player.range_labeling_active) + self.assertEqual(self.window.annotations, {}) + left_csv = pd.read_csv(self.arenas[0].annotations_path(self.project_dir)) + self.assertEqual(left_csv["rearing"].sum(), 5) + self.window.discover_arena_project(str(self.arenas[0].video_path(self.project_dir))) + self.assertEqual(self.window.active_arena_id, "left") + + def test_failed_save_prevents_arena_switch(self): + """A full disk or permission failure must not discard the current labels.""" + self.window.load_video_by_path(str(self.source)) + self.window.annotations[2] = ["rearing"] + with patch.object(ethoscore, "save_annotations_to_csv", side_effect=OSError("disk full")), \ + patch.object(QMessageBox, "warning", return_value=QMessageBox.Ok): + self.assertFalse(self.window.switch_arena("right")) + self.assertEqual(self.window.active_arena_id, "left") + self.assertEqual(self.window.annotations[2], ["rearing"]) + + def test_manual_drawing_and_name_validation(self): + """Dragging over a scaled preview creates valid source-pixel coordinates.""" + capture = cv2.VideoCapture(str(self.source)) + ok, frame = capture.read() + capture.release() + self.assertTrue(ok) + editor = ArenaEditor(self.source, frame) + editor.show() + self.app.processEvents() + preview = editor.preview + start = preview.mapFromScene(8, 10) + end = preview.mapFromScene(40, 60) + QTest.mousePress(preview.viewport(), Qt.LeftButton, Qt.NoModifier, start) + QTest.mouseMove(preview.viewport(), end) + QTest.mouseRelease(preview.viewport(), Qt.LeftButton, Qt.NoModifier, end) + self.assertEqual(len(editor.arenas), 1) + arena = editor.arenas[0] + self.assertLessEqual(abs(arena.x - 8), 1) + self.assertLessEqual(abs(arena.y - 10), 1) + self.assertGreater(arena.height, 45) + editor.add_rectangle(50, 2, 30, 60) + editor.table.item(1, 0).setText(editor.arenas[0].label) + self.assertFalse(editor.apply_button.isEnabled()) + editor.table.item(1, 0).setText("Mouse B") + self.assertTrue(editor.apply_button.isEnabled()) + editor.reject() + + def test_invalid_csv_does_not_replace_current_session(self): + """Malformed target annotations must never be treated as an empty arena.""" + self.window.load_video_by_path(str(self.source)) + self.window.annotations[4] = ['passive'] + bad_csv = self.arenas[1].annotations_path(self.project_dir) + bad_csv.write_text('Frames,passive\n1,1\n3,0\n', encoding='utf-8') + original = bad_csv.read_bytes() + with patch.object(QMessageBox, 'warning', return_value=QMessageBox.Ok): + self.assertFalse(self.window.switch_arena('right')) + self.assertEqual(self.window.active_arena_id, 'left') + self.assertEqual(self.window.video_path, str(self.arenas[0].video_path(self.project_dir))) + self.assertEqual(self.window.annotations, {4: ['passive']}) + self.assertEqual(bad_csv.read_bytes(), original) + + def test_unreadable_crop_preserves_current_session_and_csv(self): + """A broken target video must not clear the old editor or its saved data.""" + self.window.load_video_by_path(str(self.source)) + self.window.annotations[4] = ['passive'] + target = self.arenas[1].video_path(self.project_dir) + target.write_bytes(b'not a valid movie') + def autosave_during_modal(*args): + """Reproduce an autosave timer inside the decoder error's modal loop.""" + self.window.auto_save_annotations() + frame = pd.read_csv(self.arenas[0].annotations_path(self.project_dir)) + self.assertEqual(frame.loc[4, 'passive'], 1) + return QMessageBox.Ok + + with patch.object(QMessageBox, 'warning', side_effect=autosave_during_modal): + self.assertFalse(self.window.switch_arena('right')) + self.assertEqual(self.window.active_arena_id, 'left') + self.assertEqual(self.window.video_path, str(self.arenas[0].video_path(self.project_dir))) + self.assertEqual(self.window.annotations, {4: ['passive']}) + self.assertTrue(self.window.video_player.video_capture.isOpened()) + self.window.save_annotations() + frame = pd.read_csv(self.arenas[0].annotations_path(self.project_dir)) + self.assertEqual(frame.loc[4, 'passive'], 1) + + def test_failed_full_video_load_keeps_arena_and_blocks_modal_autosave(self): + """A failed normal file-open retains arena metadata and every saved label.""" + self.window.load_video_by_path(str(self.source)) + self.window.annotations[4] = ['passive'] + broken = self.root / 'broken.mp4' + broken.write_bytes(b'not a valid video') + + def autosave_during_modal(*args): + """Saving from the error dialog must leave the prior CSV untouched.""" + self.window.auto_save_annotations() + frame = pd.read_csv(self.arenas[0].annotations_path(self.project_dir)) + self.assertEqual(frame.loc[4, 'passive'], 1) + return QMessageBox.Ok + + with patch.object(QMessageBox, 'warning', side_effect=autosave_during_modal): + self.assertFalse(self.window.load_video_by_path(str(broken))) + self.assertEqual(self.window.active_arena_id, 'left') + self.assertEqual(self.window.annotations, {4: ['passive']}) + self.assertTrue(self.window.video_player.video_capture.isOpened()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/test_video_frame_integrity.py b/tests/test_video_frame_integrity.py new file mode 100644 index 0000000..693c3d2 --- /dev/null +++ b/tests/test_video_frame_integrity.py @@ -0,0 +1,143 @@ +"""GUI regression checks for cached versus direct frame identity. + +Run in the application's Python environment with unittest discovery. Offscreen +Qt and dummy SDL drivers keep this test independent of the user's live window. +""" + +import os +os.environ.setdefault("QT_QPA_PLATFORM", "offscreen") +os.environ.setdefault("SDL_AUDIODRIVER", "dummy") +os.environ.setdefault("SDL_VIDEODRIVER", "dummy") + +from pathlib import Path +import subprocess +import tempfile +import threading +import unittest + +import cv2 +import numpy as np +from PySide6.QtGui import QImage +from PySide6.QtWidgets import QApplication + +from annotator_libs.arena_export import ffmpeg_executable +from annotator_libs.video_handling import FrameCache, FramePreloader, VideoPlayer + + +class VideoFrameIntegrityTests(unittest.TestCase): + """Protect frame-index identity while background decoding and seeking.""" + + @classmethod + def setUpClass(cls): + """One GUI application serves all disposable video player tests.""" + cls.application = QApplication.instance() or QApplication([]) + + def setUp(self): + """Generate uniquely colored lossless frames in a private directory.""" + self.temporary = tempfile.TemporaryDirectory(prefix="ethoscore-frame-test-") + self.source = Path(self.temporary.name) / "numbered.mkv" + self.frames = [] + for index in range(45): + frame = np.empty((72, 96, 3), np.uint8) + frame[:] = (index * 5, 240 - index * 4, 40 + index * 3) + cv2.putText(frame, str(index), (5, 30), cv2.FONT_HERSHEY_SIMPLEX, 0.5, (255, 255, 255)) + self.frames.append(frame) + flags = subprocess.CREATE_NO_WINDOW if os.name == "nt" else 0 + result = subprocess.run([ffmpeg_executable(), "-v", "error", "-f", "rawvideo", "-pix_fmt", "bgr24", + "-s", "96x72", "-r", "30", "-i", "pipe:0", "-c:v", "ffv1", str(self.source)], + input=b"".join(frame.tobytes() for frame in self.frames), capture_output=True, creationflags=flags) + self.assertEqual(result.returncode, 0, result.stderr.decode()) + self.player = VideoPlayer() + self.player.available_behaviors = [] + + def tearDown(self): + """Join every decoder before deleting its temporary source.""" + self.player.shutdown() + self.player.close() + self.application.processEvents() + self.temporary.cleanup() + + @staticmethod + def image_array(image): + """Read RGB pixels while respecting QImage row padding.""" + image = image.convertToFormat(QImage.Format_RGB888) + rows = np.frombuffer(image.constBits(), np.uint8).reshape(image.height(), image.bytesPerLine()) + return rows[:, :image.width() * 3].reshape(image.height(), image.width(), 3).copy() + + def assert_display_frame(self, index): + """Compare the actual unscaled display image to the indexed source.""" + self.player.current_frame = index + self.player.update_frame_display() + expected = cv2.cvtColor(self.frames[index], cv2.COLOR_BGR2RGB) + actual = self.image_array(self.player.original_pixmap.toImage()) + np.testing.assert_array_equal(actual, expected) + + def test_preloader_does_not_seek_foreground_capture(self): + """A worker remains active while foreground cached/direct reads agree.""" + self.assertTrue(self.player.load_video(str(self.source), total_frames=45)) + self.player.preloader.stop() + self.player.preloader.wait() + self.player.frame_cache.clear() + initial_position = self.player.video_capture.get(cv2.CAP_PROP_POS_FRAMES) + blocked = threading.Event() + release = threading.Event() + original_put = self.player.frame_cache.put + + def delayed_worker_put(frame_number, image): + """Hold only background puts to force deterministic concurrency.""" + if threading.current_thread() is not threading.main_thread(): + blocked.set() + release.wait(timeout=5) + original_put(frame_number, image) + + self.player.frame_cache.put = delayed_worker_put + worker = FramePreloader(str(self.source), self.player.frame_cache, 20, 20, 45) + self.player.preloader = worker + worker.start() + try: + self.assertTrue(blocked.wait(timeout=5)) + self.assertTrue(worker.isRunning()) + self.assertEqual(self.player.video_capture.get(cv2.CAP_PROP_POS_FRAMES), initial_position) + for index in (1, 2, 30, 7, 8, 0, 44, 12): + self.assert_display_frame(index) + release.set() + worker.wait() + for index in (20, 21, 25, 39, 3, 4, 44, 0): + self.assert_display_frame(index) + # Force misses after cache hits, exercising the sequential hint. + self.player.frame_cache.clear() + for index in (1, 2, 43, 42, 0, 1): + self.assert_display_frame(index) + finally: + release.set() + worker.stop() + worker.wait() + + def test_exact_count_state_reset_and_bounded_cache(self): + """New arenas inherit no held labels, timers, or stale cache memory.""" + self.player.range_labeling_active["behavior"] = True + self.player.range_labeling_start["behavior"] = 12 + self.player.left_key_held = True + self.player.navigation_timer.start(100) + self.assertTrue(self.player.load_video(str(self.source), total_frames=45)) + self.assertEqual(self.player.total_frames, 45) + self.assertEqual(self.player.progress_bar.maximum(), 45) + self.assertFalse(self.player.left_key_held) + self.assertFalse(self.player.navigation_timer.isActive()) + self.assertEqual(self.player.range_labeling_active, {}) + self.assertEqual(self.player.range_labeling_start, {}) + cache = FrameCache(max_size=20, max_bytes=1000) + for index in range(20): + cache.put(index, QImage(10, 10, QImage.Format_RGB888)) + self.assertLessEqual(cache.current_bytes, 1000) + self.assertLessEqual(len(cache.cache), 3) + with self.assertRaises(TypeError): + cache.put(1, self.player.original_pixmap) + self.player.shutdown() + self.assertIsNone(self.player.video_capture) + self.assertIsNone(self.player.preloader) + self.assertEqual(self.player.frame_cache.current_bytes, 0) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/plot_export_benchmark.py b/tools/plot_export_benchmark.py new file mode 100644 index 0000000..14efc62 --- /dev/null +++ b/tools/plot_export_benchmark.py @@ -0,0 +1,131 @@ +"""Render the reproducible synthetic crop-export benchmark in three formats. + +Run from an IDE, or use ``python tools/plot_export_benchmark.py``. The CSV is +produced by ``python -m tests.test_arena_backend --benchmark`` in the application's +Python environment. This plot needs matplotlib only and uses descriptive timing +summaries. Three engineering runs do not support a general performance claim or +an inferential significance test. +""" + +from pathlib import Path +import argparse +import csv +import statistics +import textwrap + +import matplotlib +matplotlib.use("Agg") +import matplotlib.pyplot as plt + + +# User configuration: paths, figure dimensions, typography, and colors. +PROJECT_ROOT = Path(__file__).resolve().parents[1] +INPUT_CSV = PROJECT_ROOT / "tests" / "artifacts" / "arena_export_benchmark.csv" +OUTPUT_STEM = INPUT_CSV.with_suffix("") +FIGURE_SIZE_INCHES = (4.2, 3.8) +PNG_DPI = 240 +FONT_FAMILY = "DejaVu Sans" +FONT_SIZE = 9 +SHARED_COLOR = "#167d72" +BASELINE_COLOR = "#6a7484" +PAIR_COLOR = "#c6ccd3" +TEXT_COLOR = "#263343" + + +def read_paired_timings(path): + """Validate raw run records and return aligned shared/sequential timings.""" + with Path(path).open(newline="", encoding="utf-8") as handle: + rows = list(csv.DictReader(handle)) + if not rows: + raise ValueError("Benchmark CSV is empty.") + by_repeat = {} + for row in rows: + if row["validated"].lower() != "true" or int(row["output_frames_each"]) != int(row["source_frames"]): + raise ValueError("Every plotted export must pass exact frame-count validation.") + repeat, method = int(row["repeat"]), row["method"] + if method not in ("shared_decode", "sequential"): + raise ValueError(f"Unexpected benchmark method: {method}") + if method in by_repeat.setdefault(repeat, {}): + raise ValueError("Duplicate benchmark run.") + elapsed = float(row["elapsed_seconds"]) + if elapsed <= 0: + raise ValueError("Export durations must be positive.") + by_repeat[repeat][method] = elapsed + if any(set(pair) != {"shared_decode", "sequential"} for pair in by_repeat.values()): + raise ValueError("Each repeat needs one shared and one sequential export.") + pairs = [by_repeat[repeat] for repeat in sorted(by_repeat)] + return pairs, rows[0] + + +def render_benchmark(input_csv=INPUT_CSV, output_stem=OUTPUT_STEM): + """Draw paired raw observations and medians, then export PNG/PDF/SVG.""" + pairs, workload = read_paired_timings(input_csv) + shared = [pair["shared_decode"] for pair in pairs] + baseline = [pair["sequential"] for pair in pairs] + shared_median = statistics.median(shared) + baseline_median = statistics.median(baseline) + speedup = baseline_median / shared_median + plt.rcParams.update({"font.family": FONT_FAMILY, "font.size": FONT_SIZE, + "text.color": TEXT_COLOR, "axes.labelcolor": TEXT_COLOR, + "xtick.color": TEXT_COLOR, "ytick.color": TEXT_COLOR, + "axes.spines.top": False, "axes.spines.right": False, + "axes.linewidth": 0.65, "pdf.fonttype": 42, "svg.fonttype": "none"}) + figure = plt.figure(figsize=FIGURE_SIZE_INCHES) + axis = figure.add_axes((0.17, 0.35, 0.76, 0.44)) + # Aligned offsets distinguish repeated observations while preserving pairs. + count = len(pairs) + for index, pair in enumerate(pairs): + offset = (index - (count - 1) / 2) * 0.045 + axis.plot((offset, 1 + offset), (pair["shared_decode"], pair["sequential"]), + color=PAIR_COLOR, linewidth=0.8, zorder=1) + axis.scatter(offset, pair["shared_decode"], s=26, color=SHARED_COLOR, + edgecolors="white", linewidths=0.5, zorder=3) + axis.scatter(1 + offset, pair["sequential"], s=26, color=BASELINE_COLOR, + edgecolors="white", linewidths=0.5, zorder=3) + for x, median, color in ((0, shared_median, SHARED_COLOR), (1, baseline_median, BASELINE_COLOR)): + axis.plot((x - 0.13, x + 0.13), (median, median), color=color, linewidth=1.3, zorder=4) + axis.text(x + 0.16, median, f"{median:.2f} s", ha="left", va="center", fontsize=8, color=color) + axis.set_xlim(-0.3, 1.52) + axis.set_ylim(0, max(baseline + shared) * 1.18) + axis.set_xticks((0, 1), ("Shared decoder", "Sequential crops")) + axis.set_ylabel("Validated export time (s)", labelpad=7) + axis.tick_params(axis="x", length=0, pad=8) + axis.tick_params(axis="y", width=0.65, length=3) + axis.yaxis.grid(True, color="#e8ebef", linewidth=0.6) + axis.set_axisbelow(True) + figure.text(0.17, 0.94, "Arena crop export", fontsize=13, fontweight="bold", va="top") + fps = int(workload["fps_num"]) / int(workload["fps_den"]) + subtitle = (f"{workload['arenas']} arenas | {workload['source_width']} × {workload['source_height']} | " + f"{workload['source_frames']} frames at {fps:.2f} fps") + figure.text(0.17, 0.855, subtitle, fontsize=7.8, color=BASELINE_COLOR) + figure.text(0.17, 0.21, f"{speedup:.2f}× faster by median time", fontsize=10, + color=SHARED_COLOR, fontweight="bold") + note = (f"Synthetic clip; {count} paired runs. Lines join repeats; short bars mark medians. " + "Both methods include exact frame-count validation. Timing depends on hardware and video.") + figure.text(0.17, 0.14, textwrap.fill(note, width=65), va="top", + fontsize=6.7, linespacing=1.4, color=BASELINE_COLOR) + output_stem = Path(output_stem) + output_stem.parent.mkdir(parents=True, exist_ok=True) + outputs = [] + for extension in ("png", "pdf", "svg"): + output = output_stem.with_suffix(f".{extension}") + figure.savefig(output, dpi=PNG_DPI, facecolor="white") + outputs.append(output) + plt.close(figure) + summary = output_stem.with_name(output_stem.name + "_summary.csv") + with summary.open("w", newline="", encoding="utf-8") as handle: + writer = csv.DictWriter(handle, fieldnames=("paired_runs", "shared_median_seconds", + "sequential_median_seconds", "median_time_ratio")) + writer.writeheader() + writer.writerow({"paired_runs": count, "shared_median_seconds": shared_median, + "sequential_median_seconds": baseline_median, "median_time_ratio": speedup}) + return outputs + + +if __name__ == "__main__": + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--input", type=Path, default=INPUT_CSV) + parser.add_argument("--output-stem", type=Path, default=OUTPUT_STEM) + arguments = parser.parse_args() + for artifact in render_benchmark(arguments.input, arguments.output_stem): + print(artifact) diff --git a/tools/preview_arena_workflow.py b/tools/preview_arena_workflow.py new file mode 100644 index 0000000..2cae93d --- /dev/null +++ b/tools/preview_arena_workflow.py @@ -0,0 +1,119 @@ +"""Render reproducible arena UI previews using disposable synthetic footage. + +Run from the repository root with ``python tools/preview_arena_workflow.py``. +All output goes to tests/artifacts; application preferences are isolated in INI +files. This script never reads a user's recordings or registry preferences. +""" + +from pathlib import Path +import os +import subprocess +import sys +import tempfile +from unittest.mock import patch + +# Preview settings are grouped here for quick visual-layout experiments. +OUTPUT_DIR = Path(__file__).resolve().parents[1] / 'tests/artifacts' +VIDEO_WIDTH, VIDEO_HEIGHT = 800, 600 +VIDEO_FRAMES, VIDEO_FPS = 36, 30 +EDITOR_SIZE = (1180, 760) +ANNOTATOR_SIZE = (1380, 940) + +os.environ.setdefault('QT_QPA_PLATFORM', 'offscreen') +os.environ.setdefault('SDL_AUDIODRIVER', 'dummy') +sys.path.insert(0, str(Path(__file__).resolve().parents[1])) + +import cv2 +import numpy as np +from PySide6.QtCore import QSettings +from PySide6.QtGui import QFont, QFontDatabase +from PySide6.QtWidgets import QApplication + +import ethoscore +from annotator_libs.arena_model import detect_arenas, default_project_dir +from annotator_libs.arena_export import export_arenas, ffmpeg_executable +from annotator_libs.arena_ui import ArenaEditor +from installer.gui import InstallerWindow + + +def synthetic_frame(index): + """Draw four clearly divided test arenas with moving animal-shaped markers.""" + image = np.full((VIDEO_HEIGHT, VIDEO_WIDTH, 3), 165, dtype=np.uint8) + image[:, VIDEO_WIDTH // 2 - 2:VIDEO_WIDTH // 2 + 2] = 35 + image[VIDEO_HEIGHT // 2 - 2:VIDEO_HEIGHT // 2 + 2] = 35 + for arena_index in range(4): + x = 130 + arena_index % 2 * (VIDEO_WIDTH // 2) + index + y = 140 + arena_index // 2 * (VIDEO_HEIGHT // 2) + cv2.ellipse(image, (x, y), (22, 40), 30 * arena_index, 0, 360, (65, 65, 65), -1) + cv2.circle(image, (x, y - 37), 14, (55, 55, 55), -1) + cv2.line(image, (x, y + 35), (x + 20, y + 83), (65, 65, 65), 3) + return image + + +def main(): + """Render editor, selected-arena annotation, and setup screenshots.""" + OUTPUT_DIR.mkdir(parents=True, exist_ok=True) + app = QApplication.instance() or QApplication([]) + # Windows' offscreen plugin may not enumerate system fonts automatically. + # Load real installed fonts for QA without bundling proprietary font files. + if os.name == 'nt': + for font_file in (Path(os.environ.get('WINDIR', 'C:/Windows')) / 'Fonts').glob('segoeui*.ttf'): + QFontDatabase.addApplicationFont(str(font_file)) + app.setFont(QFont('Segoe UI', 10)) + with tempfile.TemporaryDirectory(prefix='ethoscore-preview-') as temporary: + root = Path(temporary) + source = root / 'four_arenas_demo.mkv' + encoded = subprocess.run([ + ffmpeg_executable(), '-v', 'error', '-f', 'rawvideo', '-pix_fmt', 'bgr24', + '-s', f'{VIDEO_WIDTH}x{VIDEO_HEIGHT}', '-r', str(VIDEO_FPS), '-i', 'pipe:0', + '-c:v', 'ffv1', str(source), + ], input=b''.join(synthetic_frame(i).tobytes() for i in range(VIDEO_FRAMES)), + capture_output=True, creationflags=getattr(subprocess, 'CREATE_NO_WINDOW', 0), check=True) + arenas = detect_arenas(synthetic_frame(0)) + if len(arenas) != 4: + raise RuntimeError('The synthetic preview must have four detectable arenas.') + editor = ArenaEditor(source, synthetic_frame(0), arenas) + editor.resize(*EDITOR_SIZE) + editor.show() + app.processEvents() + editor.table.selectRow(1) + app.processEvents() + editor.grab().save(str(OUTPUT_DIR / 'arena-editor-preview.png')) + editor.reject() + export_arenas(source, arenas, default_project_dir(source)) + + def private_settings(organization, application): + """Keep visual QA from changing the live user's settings.""" + return QSettings(str(root / f'{organization}_{application}.ini'), QSettings.IniFormat) + + with patch.object(ethoscore, 'QSettings', private_settings): + window = ethoscore.VideoAnnotator() + window.resize(*ANNOTATOR_SIZE) + window.gamepad_timer.stop() + window.load_video_by_path(str(source)) + window.arena_project.arenas[0].status = 'completed' + window.arena_project.arenas[1].status = 'ongoing' + window.arena_project.arenas[3].status = 'completed' + window.switch_arena(arenas[1].id) + window.annotations = {frame: ['rearing'] for frame in range(4, 14)} + window.video_player.annotations = window.annotations + window.update_timeline_annotations() + window.video_player.goto_frame(9) + window.show() + app.processEvents() + if window.video_player.preloader: + window.video_player.preloader.wait() + app.processEvents() + window.grab().save(str(OUTPUT_DIR / 'arena-annotator-preview.png')) + window.close() + app.processEvents() + setup = InstallerWindow() + setup.show() + app.processEvents() + setup.grab().save(str(OUTPUT_DIR / 'installer-preview.png')) + setup.close() + print(f'UI previews: {OUTPUT_DIR}') + + +if __name__ == '__main__': + main()