diff --git a/.github/workflows/build-macos-dev.yml b/.github/workflows/build-macos-dev.yml index e078c54d..8c1a4949 100644 --- a/.github/workflows/build-macos-dev.yml +++ b/.github/workflows/build-macos-dev.yml @@ -25,7 +25,20 @@ jobs: source .venv2/bin/activate python -m pip install --upgrade pip pip install -r requirements-macos.txt - pip install pyinstaller + pip install pyinstaller pytest + + - name: Run pytest from source (skip ui marker) + id: pretest + continue-on-error: true + shell: bash + run: | + source .venv2/bin/activate + python -m pytest analyzer/tests/ -m "not ui" -q --tb=short + + - name: Warn if pre-build pytest failed + if: ${{ always() && steps.pretest.outcome == 'failure' }} + shell: bash + run: echo "::warning::Pre-build pytest failed on macOS dev build; continuing to PyInstaller." - name: Smoke test CLI from source (repo root, first 2 test images) shell: bash @@ -67,47 +80,76 @@ jobs: chmod +x packaging/build_app_headless.sh ./packaging/build_app_headless.sh - - name: Smoke test built executable (first 2 test images) - id: smoke_test + - name: Run --validate against frozen binary + id: validate continue-on-error: true shell: bash run: | set -euo pipefail - SMOKE_DIR="${RUNNER_TEMP}/kestrel-smoke-input" - rm -rf "${SMOKE_DIR}" - mkdir -p "${SMOKE_DIR}" + VALIDATE_OUTPUT="${RUNNER_TEMP}/validate-result.json" + rm -f "${VALIDATE_OUTPUT}" - # mapfile is unavailable in macOS runner bash (3.2), so use while-read. - smoke_imgs=() - while IFS= read -r img; do - smoke_imgs+=("${img}") - done < <(find test_imgs -maxdepth 1 -type f | sort | head -n 2) - if [ "${#smoke_imgs[@]}" -lt 2 ]; then - echo "Expected at least 2 images in test_imgs/ for smoke test" + analyzer/dist/ProjectKestrel/ProjectKestrel \ + --cli --validate \ + --validate-images test_imgs \ + --validate-output "${VALIDATE_OUTPUT}" + + if [ ! -f "${VALIDATE_OUTPUT}" ]; then + echo "Frozen --validate did not produce a result JSON" exit 1 fi + echo "=== validate-result.json ===" + cat "${VALIDATE_OUTPUT}" - for img in "${smoke_imgs[@]}"; do - cp "${img}" "${SMOKE_DIR}/" - done + # Copy to repo root so upload-artifact finds it. + cp "${VALIDATE_OUTPUT}" validate-result.json + + ok=$(python3 -c "import json,sys; print(str(json.load(open('${VALIDATE_OUTPUT}')).get('ok')).lower())") + if [ "${ok}" != "true" ]; then + echo "Validation reported failure (ok=${ok})" + exit 1 + fi - analyzer/dist/ProjectKestrel/ProjectKestrel --cli "${SMOKE_DIR}" --no-gpu --parallel-prefetch 1 + - name: Warn if frozen --validate failed + if: ${{ always() && steps.validate.outcome == 'failure' }} + shell: bash + run: echo "::warning::Frozen-binary --validate failed on macOS dev build; continuing to publish available artifacts." - META_PATH="${SMOKE_DIR}/.kestrel/kestrel_database.csv" - if [ ! -f "${META_PATH}" ]; then - echo "Smoke test did not produce kestrel_database.csv" + - name: UI probe (real visualizer.html) against frozen binary + id: ui_probe + continue-on-error: true + shell: bash + run: | + set -euo pipefail + UI_PROBE_OUTPUT="${RUNNER_TEMP}/ui-probe-result.json" + rm -f "${UI_PROBE_OUTPUT}" + + analyzer/dist/ProjectKestrel/ProjectKestrel \ + --api-probe \ + --probe-target visualizer \ + --probe-output "${UI_PROBE_OUTPUT}" \ + --probe-timeout 60 \ + --port 8798 + + if [ ! -f "${UI_PROBE_OUTPUT}" ]; then + echo "UI probe did not produce a result JSON" exit 1 fi - PROCESSED_ROWS=$(( $(wc -l < "${META_PATH}") - 1 )) - if [ "${PROCESSED_ROWS}" -lt 2 ]; then - echo "Smoke test processed fewer than 2 images (rows=${PROCESSED_ROWS})" + echo "=== ui-probe-result.json ===" + cat "${UI_PROBE_OUTPUT}" + + cp "${UI_PROBE_OUTPUT}" ui-probe-result.json + + ok=$(python3 -c "import json,sys; print(str(json.load(open('${UI_PROBE_OUTPUT}')).get('ok')).lower())") + if [ "${ok}" != "true" ]; then + echo "UI probe reported failure (ok=${ok}); visualizer.html did not wire up window.pywebview.api" exit 1 fi - - name: Warn if smoke test failed - if: ${{ always() && steps.smoke_test.outcome == 'failure' }} + - name: Warn if UI probe failed + if: ${{ always() && steps.ui_probe.outcome == 'failure' }} shell: bash - run: echo "::warning::Smoke test failed on macOS dev build; continuing to publish available artifacts." + run: echo "::warning::Frozen-binary UI probe failed on macOS dev build; visualizer.html did not see window.pywebview.api." - name: Collect macOS .app shell: bash @@ -131,3 +173,21 @@ jobs: with: name: macos-dev-app path: artifact/ + + - name: Upload validate-result.json + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: macos-dev-validate-result + path: validate-result.json + if-no-files-found: warn + retention-days: 14 + + - name: Upload ui-probe-result.json + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: macos-dev-ui-probe-result + path: ui-probe-result.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/build-macos.yml b/.github/workflows/build-macos.yml index 28ffa2e8..b1e7ba99 100644 --- a/.github/workflows/build-macos.yml +++ b/.github/workflows/build-macos.yml @@ -63,6 +63,69 @@ jobs: exit 1 fi + - name: Generate build attestation + id: attestation + shell: bash + env: + SIGNING_SECRET: ${{ secrets.KESTREL_BUILD_SIGNING_SECRET }} + run: | + set -euo pipefail + if [ -z "${SIGNING_SECRET:-}" ]; then + echo "::warning::KESTREL_BUILD_SIGNING_SECRET is not set; skipping attestation. This build will be tagged 'legacy' by the API worker." + exit 0 + fi + if [ ! -d analyzer ]; then + echo "::error::analyzer/ directory is missing — cannot place build_attestation.json" + exit 1 + fi + + # Use python (already installed by setup-python@v5) to keep version-parsing + # logic in sync with kestrel_telemetry._read_version. Doing all work in a + # single python invocation avoids whitespace-splitting bugs in bash `read` + # when the version string contains spaces (e.g. "Nelson's Sparrow"). + python3 - <<'PY' | tee -a "$GITHUB_OUTPUT" + import hashlib, hmac, json, os, time + + def read_version(): + for cand in ("VERSION.txt", os.path.join("analyzer", "VERSION.txt")): + if not os.path.isfile(cand): + continue + with open(cand, "r", encoding="utf-8") as f: + lines = [l.strip() for l in f if l.strip()] + for line in lines: + if line.lower().startswith("version:"): + return line.split(":", 1)[1].strip() + if len(lines) == 1: + return lines[0] + return "unknown" + + version = read_version() + sha = os.environ["GITHUB_SHA"] + ts = str(int(time.time())) + meta = f"{version}|{sha}|{ts}" + sig = hmac.new( + os.environ["SIGNING_SECRET"].encode("utf-8"), + meta.encode("utf-8"), + hashlib.sha256, + ).hexdigest() + + with open("analyzer/build_attestation.json", "w", encoding="utf-8") as f: + json.dump({"meta": meta, "sig": sig}, f, separators=(",", ":")) + + # GITHUB_OUTPUT lines (tee'd by the surrounding bash so they're visible too): + print(f"version={version}") + print(f"sha={sha}") + print(f"ts={ts}") + # Diagnostics on stderr so they don't pollute GITHUB_OUTPUT: + import sys + print(f"Build attestation generated:", file=sys.stderr) + print(f" version : {version}", file=sys.stderr) + print(f" sha : {sha}", file=sys.stderr) + print(f" ts : {ts}", file=sys.stderr) + print(f" meta : {meta}", file=sys.stderr) + print(f" sig : {sig[:16]}... (truncated)", file=sys.stderr) + PY + - name: Run macOS headless build shell: bash run: | @@ -263,3 +326,49 @@ jobs: with: name: macos-bundle path: analyzer/dist/ProjectKestrel/ + + - name: Register build with worker + if: ${{ always() && steps.attestation.outputs.sha != '' }} + shell: bash + continue-on-error: true + env: + SIGNING_SECRET: ${{ secrets.KESTREL_BUILD_SIGNING_SECRET }} + BUILD_VERSION: ${{ steps.attestation.outputs.version }} + BUILD_SHA: ${{ steps.attestation.outputs.sha }} + BUILD_TS: ${{ steps.attestation.outputs.ts }} + run: | + set -euo pipefail + if [ -z "${SIGNING_SECRET:-}" ]; then + echo "::warning::No signing secret available; skipping build registration." + exit 0 + fi + + # Build JSON via python to avoid quoting headaches when version contains apostrophes. + BODY=$(BUILD_VERSION="$BUILD_VERSION" BUILD_SHA="$BUILD_SHA" BUILD_TS="$BUILD_TS" \ + RUN_ID="$GITHUB_RUN_ID" ACTOR="$GITHUB_ACTOR" python3 -c ' + import json, os + print(json.dumps({ + "version": os.environ["BUILD_VERSION"], + "sha": os.environ["BUILD_SHA"], + "ts": os.environ["BUILD_TS"], + "run_id": os.environ["RUN_ID"], + "actor": os.environ["ACTOR"], + })) + ') + + # --fail makes curl exit non-zero on HTTP errors; --max-time 15 bounds the call. + # Capture both stdout and exit code so failure is logged but non-blocking. + set +e + RESP=$(curl --silent --show-error --fail --max-time 15 \ + -H "X-Kestrel-Signing-Token: ${SIGNING_SECRET}" \ + -H "Content-Type: application/json" \ + --data-raw "${BODY}" \ + "https://api.projectkestrel.org/api/builds/register" 2>&1) + RC=$? + set -e + + if [ "$RC" -eq 0 ]; then + echo "Build registered with worker: ${RESP}" + else + echo "::warning::Build registration failed (non-blocking, rc=${RC}): ${RESP}" + fi diff --git a/.github/workflows/dev-windows-build.yml b/.github/workflows/dev-windows-build.yml new file mode 100644 index 00000000..f0c666f4 --- /dev/null +++ b/.github/workflows/dev-windows-build.yml @@ -0,0 +1,139 @@ +name: Build Windows Dev Bundle + +# Dev-only workflow. Mirrors build-macos-dev.yml on Windows. Unlike main.yml, +# this skips Inno Setup / MSIX / signing — it stops at the PyInstaller onedir +# bundle and uploads it directly so developers can grab a working binary +# without paying the full production-pipeline cost. + +on: + workflow_dispatch: + +jobs: + build-windows-dev: + runs-on: windows-latest + steps: + - name: Checkout repo + uses: actions/checkout@v4 + with: + lfs: true + + - name: Set up Python 3.11 + uses: actions/setup-python@v5 + with: + python-version: "3.11" + + - name: Create venv and install dependencies + shell: powershell + run: | + python -m venv .venv2 + .\.venv2\Scripts\Activate.ps1 + python -m pip install --upgrade pip + pip install -r requirements-windows.txt + pip install pyinstaller pytest + + - name: Run pytest from source (skip ui marker) + id: pretest + continue-on-error: true + shell: powershell + run: | + .\.venv2\Scripts\Activate.ps1 + python -m pytest analyzer/tests/ -m "not ui" -q --tb=short + + - name: Warn if pre-build pytest failed + if: ${{ always() && steps.pretest.outcome == 'failure' }} + shell: powershell + run: Write-Host "::warning::Pre-build pytest failed on Windows dev build; continuing to PyInstaller." + + - name: Build ProjectKestrel with PyInstaller + shell: powershell + run: | + .\.venv2\Scripts\Activate.ps1 + cd analyzer + python -m PyInstaller ProjectKestrel.spec + + - name: Run --validate against frozen binary + id: validate + continue-on-error: true + shell: powershell + run: | + $validateOutput = Join-Path $env:RUNNER_TEMP "validate-result.json" + & "analyzer\dist\ProjectKestrel\ProjectKestrel.exe" ` + --cli --validate ` + --validate-images test_imgs ` + --validate-output $validateOutput + + if (!(Test-Path $validateOutput)) { + throw "Frozen --validate did not produce a result JSON at $validateOutput" + } + Write-Host "=== validate-result.json ===" + Get-Content $validateOutput + + # Copy to repo root so the upload-artifact step finds it. + Copy-Item $validateOutput "validate-result.json" + + $report = Get-Content $validateOutput | ConvertFrom-Json + if (-not $report.ok) { + throw "Validation reported failure (ok=false)" + } + + - name: Warn if frozen --validate failed + if: ${{ always() && steps.validate.outcome == 'failure' }} + shell: powershell + run: Write-Host "::warning::Frozen-binary --validate failed on Windows dev build; continuing to publish available artifacts." + + - name: UI probe (real visualizer.html) against frozen binary + id: ui_probe + continue-on-error: true + shell: powershell + run: | + $probeOut = Join-Path $env:RUNNER_TEMP "ui-probe-result.json" + if (Test-Path $probeOut) { Remove-Item $probeOut } + & "analyzer\dist\ProjectKestrel\ProjectKestrel.exe" ` + --api-probe ` + --probe-target visualizer ` + --probe-output $probeOut ` + --probe-timeout 60 ` + --port 8798 + + if (!(Test-Path $probeOut)) { + throw "UI probe did not produce a result JSON at $probeOut" + } + Write-Host "=== ui-probe-result.json ===" + Get-Content $probeOut + + Copy-Item $probeOut "ui-probe-result.json" + $report = Get-Content $probeOut | ConvertFrom-Json + if (-not $report.ok) { + throw "UI probe reported failure (ok=false); visualizer.html did not wire up window.pywebview.api" + } + + - name: Warn if UI probe failed + if: ${{ always() && steps.ui_probe.outcome == 'failure' }} + shell: powershell + run: Write-Host "::warning::Frozen-binary UI probe failed on Windows dev build; visualizer.html did not see window.pywebview.api." + + - name: Upload Windows dev bundle + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: windows-dev-bundle + path: analyzer/dist/ProjectKestrel/ + retention-days: 14 + + - name: Upload validate-result.json + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: windows-dev-validate-result + path: validate-result.json + if-no-files-found: warn + retention-days: 14 + + - name: Upload ui-probe-result.json + if: ${{ always() }} + uses: actions/upload-artifact@v4 + with: + name: windows-dev-ui-probe-result + path: ui-probe-result.json + if-no-files-found: warn + retention-days: 14 diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index 9d692c24..bd233446 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -125,6 +125,63 @@ jobs: throw "Source CLI smoke test processed fewer than 2 images (rows=$processedRows)" } + - name: Generate build attestation + id: attestation + shell: powershell + env: + SIGNING_SECRET: ${{ secrets.KESTREL_BUILD_SIGNING_SECRET }} + run: | + if (-not $env:SIGNING_SECRET) { + Write-Host "::warning::KESTREL_BUILD_SIGNING_SECRET is not set; skipping attestation. This build will be tagged 'legacy' by the API worker." + exit 0 + } + + # Read version from VERSION.txt (root or analyzer/) using the same logic as + # kestrel_telemetry._read_version: prefer a 'version:' line, else single-line fallback. + $version = 'unknown' + foreach ($candidate in @('VERSION.txt', 'analyzer\VERSION.txt')) { + if (Test-Path $candidate) { + $lines = Get-Content $candidate | Where-Object { $_.Trim() -ne '' } + foreach ($line in $lines) { + if ($line.ToLower().StartsWith('version:')) { + $version = ($line -split ':', 2)[1].Trim() + break + } + } + if ($version -eq 'unknown' -and $lines.Count -eq 1) { + $version = $lines[0].Trim() + } + if ($version -ne 'unknown') { break } + } + } + + $sha = $env:GITHUB_SHA + $ts = [int][double]::Parse((Get-Date -UFormat %s)) + $meta = "$version|$sha|$ts" + + $hmac = New-Object System.Security.Cryptography.HMACSHA256 + $hmac.Key = [Text.Encoding]::UTF8.GetBytes($env:SIGNING_SECRET) + $sigBytes = $hmac.ComputeHash([Text.Encoding]::UTF8.GetBytes($meta)) + $sig = -join ($sigBytes | ForEach-Object { $_.ToString('x2') }) + + if (-not (Test-Path 'analyzer')) { + Write-Host "::error::analyzer/ directory is missing — cannot place build_attestation.json" + exit 1 + } + $payload = @{ meta = $meta; sig = $sig } | ConvertTo-Json -Compress + Set-Content -Path 'analyzer\build_attestation.json' -Value $payload -Encoding utf8 -NoNewline + + Write-Host "Build attestation generated:" + Write-Host " version : $version" + Write-Host " sha : $sha" + Write-Host " ts : $ts" + Write-Host " meta : $meta" + Write-Host " sig : $($sig.Substring(0, 16))... (truncated)" + + Add-Content -Path $env:GITHUB_OUTPUT -Value "version=$version" + Add-Content -Path $env:GITHUB_OUTPUT -Value "sha=$sha" + Add-Content -Path $env:GITHUB_OUTPUT -Value "ts=$ts" + - name: Build ProjectKestrel with PyInstaller shell: cmd run: packaging\build_installer_headless.bat @@ -250,3 +307,39 @@ jobs: with: name: windows-bundle path: analyzer/dist/ProjectKestrel/ + + - name: Register build with worker + if: ${{ always() && steps.attestation.outputs.sha != '' }} + shell: powershell + continue-on-error: true + env: + SIGNING_SECRET: ${{ secrets.KESTREL_BUILD_SIGNING_SECRET }} + BUILD_VERSION: ${{ steps.attestation.outputs.version }} + BUILD_SHA: ${{ steps.attestation.outputs.sha }} + BUILD_TS: ${{ steps.attestation.outputs.ts }} + run: | + if (-not $env:SIGNING_SECRET) { + Write-Host "::warning::No signing secret available; skipping build registration." + exit 0 + } + $body = @{ + version = $env:BUILD_VERSION + sha = $env:BUILD_SHA + ts = $env:BUILD_TS + run_id = $env:GITHUB_RUN_ID + actor = $env:GITHUB_ACTOR + } | ConvertTo-Json -Compress + + try { + $resp = Invoke-RestMethod ` + -Uri 'https://api.projectkestrel.org/api/builds/register' ` + -Method POST -TimeoutSec 15 ` + -Headers @{ + 'X-Kestrel-Signing-Token' = $env:SIGNING_SECRET + 'Content-Type' = 'application/json' + } ` + -Body $body + Write-Host "Build registered with worker: $($resp | ConvertTo-Json -Compress)" + } catch { + Write-Host "::warning::Build registration failed (non-blocking): $($_.Exception.Message)" + } diff --git a/.gitignore b/.gitignore index b067e848..46a76d86 100644 --- a/.gitignore +++ b/.gitignore @@ -39,10 +39,9 @@ tmp_exposure_solver_benchmark_report.json tmp_exposure_solver_benchmark_rounds.py tmp* .venv-speciesnet/ -.kestrel* +test_imgs/.kestrel* documentation/ .claude* EXPCOMP_tests/ _kestrel* -.tmp* -tests* \ No newline at end of file +.tmp* \ No newline at end of file diff --git a/DEVELOPMENT.md b/DEVELOPMENT.md index a8a823fa..c17b0f6f 100644 --- a/DEVELOPMENT.md +++ b/DEVELOPMENT.md @@ -1,229 +1,256 @@ # Project Kestrel - Development & Packaging Guide -## Project Structure +## Architecture + +Kestrel is a single desktop application: a pywebview shell wrapping a vanilla +JS/HTML/CSS UI, with a Python backend that exposes an `Api` class to JS via the +pywebview bridge. There is no PyQt GUI, no separate visualizer server, no SPA +framework. ``` ProjectKestrel/ -├── analyzer/ # Analyzer application (GUI + CLI) -│ ├── gui_app.py # PyQt6 GUI entry point -│ ├── cli.py # CLI entry point (headless mode) -│ ├── main.py # Default GUI launcher -│ ├── gui_helpers.py # GUI utilities (QImage conversion) -│ ├── models/ # AI model files -│ │ ├── model.onnx # Bird species classifier (ONNX) -│ │ ├── labels.txt # Species labels -│ │ ├── quality.keras # Quality assessment model (macOS-compatible re-save) -│ │ ├── quality_old.keras # Backup of original quality model +├── analyzer/ # Single unified app +│ ├── visualizer.py # App entry: pywebview window + local-only HTTP server +│ ├── api_bridge.py # `Api` class — every JS↔Python call lands here +│ ├── queue_manager.py # Sequential folder-analysis queue worker +│ ├── settings_utils.py # Atomic settings.json I/O + schema validation +│ ├── cli.py # Headless CLI entry +│ ├── visualizer.html # Main window markup (all dialogs inline) +│ ├── visualizer.js # Main window logic (~10k lines, vanilla JS) +│ ├── visualizer.css # Main window styles +│ ├── culling.html # Culling Assistant window (separate webview) +│ ├── metadata_writer.py # XMP sidecar writer +│ ├── editor_launch.py # Open photos in external editors +│ ├── folder_inspector.py # Lightweight folder probing (no ML) +│ ├── shutdown_watch.py # OS-shutdown / logoff detection +│ ├── kestrel_telemetry.py # Cloudflare Worker API client (failsafe) +│ ├── runtime_hook.py # PyInstaller runtime hook (Windows DLL search) +│ ├── ProjectKestrel.spec # PyInstaller spec (Windows) +│ ├── ProjectKestrel-macos.spec # PyInstaller spec (macOS) +│ ├── models/ # Bundled AI model files +│ │ ├── model.onnx # Bird species classifier +│ │ ├── labels.txt │ │ ├── labels_scispecies.csv -│ │ └── scispecies_dispname.csv -│ └── kestrel_analyzer/ # Core analysis pipeline (no GUI) -│ ├── __init__.py -│ ├── config.py # Configuration and constants -│ ├── database.py # Database operations -│ ├── pipeline.py # Main analysis pipeline -│ ├── image_utils.py # Image I/O utilities -│ ├── similarity.py # Image similarity detection -│ ├── ratings.py # Quality score to rating conversion -│ └── ml/ # Machine learning model wrappers -│ ├── speciesnet_sam_hq.py # SpeciesNet + SAM-HQ detection/segmentation -│ ├── speciesnet_taxonomy.py -│ ├── bird_species.py # Bird species classification -│ └── quality.py # Image quality assessment -│ -├── visualizer/ # Visualizer application (web-based) -│ ├── visualizer.py # Local web server entry -│ └── visualizer.html # Web UI -│ -├── scripts/ # Utility scripts -│ └── resave_quality_model.py # Re-saves quality.keras for macOS compatibility -│ -├── packaging/ # PyInstaller specs for EXE builds -│ ├── analyzer/ -│ │ └── kestrel_analyzer.spec -│ └── visualizer/ -│ └── kestrel_visualizer.spec -│ -├── requirements.txt # Python dependencies -└── README.md +│ │ ├── scispecies_dispname.csv +│ │ ├── quality.onnx # Quality assessment model +│ │ ├── quality_normalization_data.csv +│ │ └── speciesnet/ # MegaDetector ONNX + SAM-HQ encoder/decoder + SpeciesNet weights +│ ├── kestrel_analyzer/ # Core analysis pipeline (no UI dependencies) +│ │ ├── pipeline.py # Main orchestration (`AnalysisPipeline`) +│ │ ├── config.py # Paths and constants +│ │ ├── database.py # kestrel_database.csv I/O + scenedata migration +│ │ ├── image_utils.py, raw_exif.py, similarity.py, ratings.py, ... +│ │ └── ml/ # SpeciesNet+SAM-HQ wrapper, species classifier, quality classifier +│ └── tests/ # pytest + unittest +├── packaging/ # MSIX manifest + installer build scripts +│ ├── ProjectKestrel.appxmanifest +│ ├── build_installer_headless.bat # Windows: PyInstaller build +│ ├── build_installer_headless_part2.bat # Windows: Inno Setup installer +│ ├── build_app_headless.sh # macOS PyInstaller build +│ └── kestrel_installer.iss # Inno Setup script +├── utils/ +│ └── resave_quality_model.py # Dev-only: re-save Keras model (historical) +├── test_imgs/ # Tiny CI smoke-test images +├── EXPCOMP_tests/ # Exposure-compensation reference data +├── sample_sets/ # Bundled sample bird-photo sets shown in onboarding +├── requirements.txt # Generic / Linux-from-source dependencies +├── requirements-windows.txt # DirectML build (Windows) +└── requirements-macos.txt # CoreML / Apple Silicon ``` ## Development Setup -### 1. Clone and Install Dependencies +### 1. Clone and install dependencies ```bash git clone https://github.com/SanjaySoniLV/ProjectKestrel.git cd ProjectKestrel -pip install -r requirements.txt +pip install -r requirements-windows.txt # or requirements-macos.txt / requirements.txt ``` -If `pip` installs both `opencv-python` and `opencv-python-headless` (e.g. via `speciesnet`), `cv2.imwrite` may reject the JPEG quality parameter. For this app, keep the full GUI wheel and run: `pip uninstall -y opencv-python-headless`, then **`pip install --force-reinstall opencv-python==4.11.0.86`** so the `cv2` extension is intact (uninstalling headless can leave a broken `cv2` namespace). Re-pin **`numpy==2.1.3`** afterward if pip upgrades NumPy (TensorFlow expects `numpy<2.2`). - -### 2. Running the Analyzer +If `pip` installs both `opencv-python` and `opencv-python-headless` (e.g. via +`speciesnet`), `cv2.imwrite` may reject the JPEG-quality parameter. Fix: -**GUI Mode (default):** ```bash -python analyzer/gui_app.py -# or -python analyzer/main.py +pip uninstall -y opencv-python-headless +pip install --force-reinstall opencv-python==4.11.0.86 ``` -**Model Files Required:** -All models must be in `analyzer/models/`: -- `model.onnx` (bird species classifier) -- `labels.txt`, `labels_scispecies.csv`, `scispecies_dispname.csv` -- `quality.keras` (quality assessment) -- `sam_hq_vit_tiny.pth` (SAM-HQ ViT-Tiny segmentation checkpoint; faster than ViT-B; bundled for CI builds) -- SpeciesNet detector/classifier weights (downloaded automatically on first run via the `speciesnet` package) +Re-pin `numpy==2.1.3` afterward if pip upgrades it — TensorFlow expects +`numpy<2.2`. -**CLI Mode (headless):** -```bash -python analyzer/cli.py "C:\path\to\photos" --no-gpu -python analyzer/cli.py "C:\path\to\photos" --gpu -``` +### 2. Required model files -### 3. Running the Visualizer +All under `analyzer/models/`: +- `model.onnx`, `labels.txt`, `labels_scispecies.csv`, `scispecies_dispname.csv` +- `quality.onnx`, `quality_normalization_data.csv` +- `speciesnet/` (MegaDetector ONNX variants, SAM-HQ ViT-Tiny encoder/decoder ONNX, SpeciesNet weights) -```bash -python visualizer/visualizer.py --port 8765 --root "C:\path\to\analyzed\photos" -``` - -Security and runtime mode notes: -- The visualizer is desktop-first and requires pywebview. -- Browser-only fallback mode is intentionally unsupported. -- Keep local API bridge security checks aligned with the token+origin policy. -- Legacy HTTP control routes are disabled by default; compatibility mode requires explicitly setting `KESTREL_ENABLE_LEGACY_HTTP_API=1` (and optionally `KESTREL_ENABLE_LEGACY_OPEN_ENDPOINT=1` for legacy `/open`). -- Persisted settings are schema-sanitized and clamped on both load and save; unsupported keys are dropped. -- Path-taking bridge calls enforce root-boundary checks and log rejection events for auditability. +SpeciesNet weights are bundled in the repo via Git LFS. SAM-HQ ViT-Tiny ONNX +files are also bundled. No first-run downloads are needed. -## Building Executables - -### Prerequisites +### 3. Running the app +Desktop (pywebview) — the normal launch: ```bash -pip install pyinstaller +python analyzer/visualizer.py ``` -### Build Analyzer EXE - +Headless CLI: ```bash -cd ProjectKestrel -pyinstaller packaging/analyzer/kestrel_analyzer.spec -``` - -Output: `dist/kestrel_analyzer/kestrel_analyzer.exe` - -### Build Visualizer EXE +python analyzer/cli.py "C:\path\to\photos" --no-gpu +python analyzer/cli.py "C:\path\to\photos" --gpu --parallel-prefetch 3 -```bash -cd ProjectKestrel -pyinstaller packaging/visualizer/kestrel_visualizer.spec +# Equivalent: forward through the same entry point +python analyzer/visualizer.py --cli "C:\path\to\photos" --no-gpu ``` -Output: `dist/kestrel_visualizer/kestrel_visualizer.exe` - -## Code Organization - -### Core Pipeline (Reusable) -The `analyzer/kestrel_analyzer/` package contains all business logic with **zero GUI dependencies**: -- **pipeline.py**: Main orchestration class -- **database.py**: CSV database operations -- **image_utils.py, similarity.py, ratings.py**: Utility functions -- **ml/*.py**: Model wrappers (ONNX, Keras, Torch) +Optional environment variables: +- `KESTREL_ALLOWED_ROOT` — jail bridge calls to this root directory. +- `KESTREL_ALLOWED_EXTENSIONS` — override the editor-launch extension allowlist + (comma-separated list, e.g. `.cr3,.jpg`). -This allows the pipeline to be: -- ✅ Used by GUI (PyQt6) -- ✅ Used by CLI (command-line) -- ✅ Used by web services (FastAPI, Flask) -- ✅ Used by third-party tools +### 4. Output layout -### GUI Layer (PyQt6) -- **gui_app.py**: Main GUI window and worker thread -- **gui_helpers.py**: Qt-specific utilities -- **main.py**: Entry point that launches GUI +For each analyzed folder, Kestrel creates `.kestrel/` containing: +- `kestrel_database.csv` — per-image analysis results +- `kestrel_scenedata.json` — scene grouping + user-edited ratings/tags +- `kestrel_metadata.json` — analysis-run audit trail (settings used, render mode) +- `export/` — resized JPEG thumbnails for the UI +- `crop/` — square bird crops for the UI +- `culling_TMP/` — RAW preview cache (deleted on app close) -### CLI Layer -- **cli.py**: Argument parsing and CLI-specific formatting - -### Visualizer (Standalone Web Service) -- **visualizer.py**: HTTP server that serves visualizer.html -- **visualizer.html**: Web UI for browsing results - -## Deployment Strategy - -### Single-File Distribution -Both applications can be packaged as single-file executables: -- `kestrel_analyzer.exe` (~500MB with all dependencies) -- `kestrel_visualizer.exe` (~50MB) - -### Installation Options -1. **Portable ZIP**: Unzip and run executable -2. **MSI Installer**: Use WiX Toolset or similar -3. **Windows Store**: Package as MSIX - -## Module Dependencies +## Testing -**External Dependencies** (from requirements.txt): -- torch, torchvision (SpeciesNet, SAM-HQ) -- speciesnet, segment-anything-hq -- tensorflow (Quality classifier) -- onnxruntime (Bird species classifier) -- opencv-python, pillow, wand (Image processing) -- pandas, numpy (Data handling) -- PyQt6 (GUI only) +Configured in `analyzer/tests/pytest.ini`. Markers: `unit`, `integration`, `e2e`, +`compat`, `ui`. -**Internal Imports:** -- CLI and GUI both import from `kestrel_analyzer` package -- No circular dependencies -- All ML models loaded lazily in pipeline +```bash +pytest analyzer/tests -m unit +pytest analyzer/tests/unit/test_database.py -v +pytest analyzer/tests/unit/test_database.py::TestName::test_case +``` -## Reproducing the macOS-compatible Quality Model +`conftest.py` puts `analyzer/` on `sys.path`, so tests import as +`from kestrel_analyzer.database import ...` (not `from analyzer.kestrel_analyzer...`). -The `analyzer/models/quality.keras` file has been re-saved using `compile=False` -and a synthetic forward pass to materialise all weights. This reduces -TF/XLA serialization incompatibilities observed on macOS (Apple Silicon). -The original model is preserved as `quality_old.keras`. +Taxonomy-routing test (no ML weights needed, no pytest required): +```bash +# Windows +$env:PYTHONPATH = "analyzer" +python -m unittest analyzer.tests.test_speciesnet_taxonomy -v -To re-run the process (e.g. after updating the model): +# POSIX +PYTHONPATH=analyzer python -m unittest analyzer.tests.test_speciesnet_taxonomy -v +``` +Pipeline smoke test on the bundled images: ```bash -# Install macOS requirements first -pip install -r requirements-macos.txt - -# Run from repository root -python scripts/resave_quality_model.py +python analyzer/cli.py test_imgs --no-gpu --parallel-prefetch 1 ``` -The script prints the TF version, model input shape, and confirms inference -output shape. Optional flags: `--model-dir`, `--input-name`, `--old-name`. +## Building Executables -## Testing +### Windows -Taxonomy routing (fast, no GPU models): -```bash -set PYTHONPATH=analyzer -python -m unittest analyzer.tests.test_speciesnet_taxonomy -v +```cmd +packaging\build_installer_headless.bat +packaging\build_installer_headless_part2.bat ``` -To test the CLI locally (requires `sam_hq_vit_tiny.pth` under `analyzer/models/` and SpeciesNet weight download on first run): -```bash -python analyzer/cli.py test_imgs --no-gpu -``` +- `build_installer_headless.bat` runs PyInstaller against + `analyzer/ProjectKestrel.spec` and produces `analyzer/dist/ProjectKestrel/`. +- `build_installer_headless_part2.bat` packages with Inno Setup into + `dist/installer/*.exe`. + +CI (`.github/workflows/main.yml`) also builds an MSIX/MSIXBUNDLE via `makeappx` +using `packaging/ProjectKestrel.appxmanifest`. -For a quick RAW smoke test on one file, use e.g. `test_imgs\IMG_4470.CR3` inside that folder (case may vary by camera; `.CR3` is Canon RAW). +### macOS -To test the GUI: ```bash -python analyzer/gui_app.py +packaging/build_app_headless.sh ``` -Then select `test_imgs` folder and click Start. +Targets `analyzer/ProjectKestrel-macos.spec`. -## Migration Notes +Both spec files use `analyzer/visualizer.py` as the entry point. + +## Code Organization -This refactoring achieves: -- ✅ **Separation of Concerns**: Core logic separate from UI -- ✅ **Dual Interfaces**: GUI and CLI share same pipeline -- ✅ **Packaging Ready**: Clear structure for executables -- ✅ **No Duplication**: One code path for both interfaces -- ✅ **Extensibility**: Easy to add web API or other interfaces later +### `analyzer/kestrel_analyzer/` — pure pipeline (no UI dependencies) + +The pipeline is reusable from the CLI, the queue manager, or any future +caller. All ML model loading is lazy and triggered by +`AnalysisPipeline.load_models()`. Key modules: + +- `pipeline.py` — `AnalysisPipeline.process_folder()` is the main entry. Drives + decode → detection (SpeciesNet + SAM-HQ) → species classification → quality + scoring → scene grouping → CSV write. +- `database.py` — `kestrel_database.csv` I/O. Includes a one-time migration that + moves user-editable columns (`rating`, `scene_name`, etc.) out of the CSV + and into `kestrel_scenedata.json`. +- `ml/speciesnet_sam_hq.py` — SpeciesNet (MegaDetector + classifier) + SAM-HQ + ViT-Tiny wrapper, all on ONNX Runtime with DirectML/CoreML/CPU provider + coordination. +- `ml/bird_species.py` — Custom ONNX bird species classifier. +- `ml/quality.py` — Custom ONNX quality model with percentile normalization. +- `ratings.py` — Quality score → 1–5 star mapping using hardcoded + per-profile thresholds (`very_strict`, `strict`, `balanced`, `lenient`, + `very_lenient`). + +### `analyzer/` — application shell (pywebview + bridge + queue) + +- `visualizer.py` — Process entry. Owns the pywebview window, the local-only + HTTP server that serves `visualizer.html`, session lifecycle (clean-exit / + OS-shutdown / crash detection), and the crash-report handler. +- `api_bridge.py` — `Api` class. Every JS call comes through here. Adding a + bridge call means: (1) new method on `Api`, (2) JS call site in + `visualizer.js`, (3) settings-schema update in `settings_utils.py` if it + persists. +- `queue_manager.py` — Sequential folder analysis queue. Lazy-imports + `kestrel_analyzer.pipeline` so the app starts fast for browse-only sessions. + Snapshots settings at enqueue time — changing settings mid-run does not + affect the running job. +- `settings_utils.py` — Atomic `settings.json` I/O with schema validation, + monotonic counter guards, and `.bak` recovery. Protected by `_SAVE_LOCK` + because the JS bridge, queue worker, and startup path all write concurrently. + +### Settings flow + +1. Backend persists `settings.json` at a platform-specific path + (`%LOCALAPPDATA%\ProjectKestrel\` on Windows, + `~/Library/Application Support/ProjectKestrel/` on macOS). +2. Frontend mirrors into `localStorage` under key `kestrel-webviz-settings-v1`. +3. JS reads/writes via `window.pywebview.api.get_settings()` / + `save_settings_data()`. +4. Unknown keys are dropped with size limits on save; new persisted settings + require an entry in `_sanitize_settings_payload()` in + `analyzer/settings_utils.py`. + +## Security Posture + +- All control flows through the pywebview JS bridge. The local HTTP server + only serves static files (GET) on 127.0.0.1. +- Bridge methods that accept paths jail every operation under a validated root + via `_validate_root_dir` + `_is_within_root` + `_resolve_path_in_root`. +- `metadata_writer._safe_sidecar_path` rejects sidecar filenames containing + path separators, drive letters, UNC prefixes, or traversal segments. +- `editor_launch._validate_custom_editor_path` rejects UNC, relative paths, + control chars, and non-existent targets before `Popen`. +- `api_bridge.open_url` allowlists `http`, `https`, `mailto` schemes — no + `file://`, `javascript:`, `data:`, custom URI handlers, or UNC paths. +- Static file serving sends a strict CSP plus `X-Content-Type-Options`, + `X-Frame-Options: DENY`, and `Referrer-Policy: no-referrer`. + +## GPU Acceleration + +GPU support is opt-in and currently considered Beta. On Windows the analyzer +uses DirectML via `onnxruntime-directml`; on macOS, CoreML via +`onnxruntime-coreml`. CPU mode works everywhere. The +`provider_coordinator.py` module handles automatic GPU→CPU fallback on +session failure. + +Released installers ship CPU-only. Running from source on Windows with +`requirements-windows.txt` enables DirectML; the `--gpu` flag on `cli.py` +opts in. diff --git a/README.md b/README.md index 0b470406..48265dd5 100644 --- a/README.md +++ b/README.md @@ -118,16 +118,24 @@ Features of the visualizer: ``` ProjectKestrel/ -├── analyzer/ # Analyzer app (GUI + CLI + core pipeline) -│ ├── gui_app.py # PyQt GUI entry -│ ├── cli.py # CLI entry -│ ├── main.py # GUI entrypoint wrapper -│ ├── models/ # AI model files -│ └── kestrel_analyzer/ # Core pipeline + ML wrappers -├── visualizer/ # Visualizer app (local web server) -│ ├── visualizer.py # Server entry -│ └── visualizer.html # Web UI -├── packaging/ # PyInstaller specs for .exe builds +├── analyzer/ # Single unified app (desktop UI + CLI + pipeline) +│ ├── visualizer.py # App entry: launches the pywebview window +│ ├── api_bridge.py # JS↔Python bridge (Api class exposed to the UI) +│ ├── queue_manager.py # Sequential folder-analysis queue +│ ├── settings_utils.py # settings.json I/O with schema validation +│ ├── cli.py # Headless CLI entry +│ ├── visualizer.html # UI markup +│ ├── visualizer.js # UI logic (vanilla JS) +│ ├── visualizer.css # UI styles +│ ├── culling.html # Culling Assistant window +│ ├── metadata_writer.py # XMP sidecar writer +│ ├── editor_launch.py # Open photos in external editors +│ ├── models/ # AI model files (ONNX + SpeciesNet) +│ ├── kestrel_analyzer/ # Core analysis pipeline (no UI dependencies) +│ └── tests/ # pytest + unittest test suites +├── packaging/ # PyInstaller specs + installer build scripts +├── utils/ # Developer utility scripts +├── test_imgs/ # Tiny smoke-test images for CI └── README.md ``` @@ -141,11 +149,16 @@ Kestrel's quality scoring model is trained on RAW images, and may not work as we - Sony: `.arw` - Adobe: `.dng` - Olympus: `.orf` -- Fuji: `.raf` +- Fuji: `.raf` * - Panasonic: `.rw2` - Pentax: `.pef` -- Samsung: `.sr2` -- Sigma: `.x3f` +- Samsung: `.sr2`, `.srw` +- Sigma: `.x3f` * + +> * For `.raf` and `.x3f`, Kestrel's native capture-time parser does not +> yet support the EXIF layout used by these formats. The images analyze +> normally, but scene grouping falls back to image-feature similarity +> instead of using EXIF timestamps. Full timestamp support is planned. > Note: If this list does not support your camera's RAW file, please reach out via the email below. It is easy to add new RAW file formats thanks to the rawpy library. diff --git a/VERSION.txt b/VERSION.txt index 5c2e8e3b..17832cf1 100644 --- a/VERSION.txt +++ b/VERSION.txt @@ -1 +1 @@ -Gambel's Quail (Plume Fix) \ No newline at end of file +Nelson's Sparrow \ No newline at end of file diff --git a/analyzer/ProjectKestrel-macos.spec b/analyzer/ProjectKestrel-macos.spec index 59e32042..d064c8d1 100644 --- a/analyzer/ProjectKestrel-macos.spec +++ b/analyzer/ProjectKestrel-macos.spec @@ -10,13 +10,22 @@ from PyInstaller.utils.hooks import collect_all print(os.listdir("sample_sets")) # Build datas list with proper sample_sets bundling using Tree() # models/ includes bundled SpeciesNet (models/speciesnet/info.json + weights) and SAM-HQ checkpoints. -datas = [('models', 'models'), ('kestrel_telemetry.py', '.'), ('folder_inspector.py', '.'), ('cli.py', '.'), ('VERSION.txt', '.'), ('kestrel_analyzer', 'kestrel_analyzer'), ('visualizer.html', '.'), ('visualizer.css', '.'), ('visualizer.js', '.'), ('csv_parser.js', '.'), ('culling.html', '.'), ('logo.png', '.'), ('logo.ico', '.'), ('settings_utils.py', '.'), ('editor_launch.py', '.'), ('queue_manager.py', '.'), ('api_bridge.py', '.')] +datas = [('models', 'models'), ('kestrel_telemetry.py', '.'), ('build_attestation.py', '.'), ('folder_inspector.py', '.'), ('cli.py', '.'), ('VERSION.txt', '.'), ('kestrel_analyzer', 'kestrel_analyzer'), ('visualizer.html', '.'), ('css', 'css'), ('visualizer.js', '.'), ('csv_parser.js', '.'), ('culling.html', '.'), ('logo.png', '.'), ('logo.ico', '.'), ('settings_utils.py', '.'), ('editor_launch.py', '.'), ('queue_manager.py', '.'), ('api_bridge.py', '.')] + +# CI generates build_attestation.json with the HMAC attestation for official builds. +# Source builds and dev workflows skip generation, in which case the bundle just +# doesn't include the file and the client falls back to the legacy auth header. +if os.path.exists('build_attestation.json'): + datas.append(('build_attestation.json', '.')) + print('[spec] Including build_attestation.json (official build).') +else: + print('[spec] No build_attestation.json present (source/dev build).') # Add sample_sets using Tree() - convert 3-element tuples to 2-element format for datas sample_sets_tree = Tree('sample_sets', prefix='sample_sets') datas += [(item[0], item[1]) for item in sample_sets_tree] # Only use first 2 elements of each tuple binaries = [] -hiddenimports = ['pywebview', 'certifi','PIL','exifread','settings_utils','editor_launch','queue_manager','api_bridge'] +hiddenimports = ['pywebview', 'certifi','PIL','exifread','settings_utils','editor_launch','queue_manager','api_bridge','build_attestation'] binaries += collect_dynamic_libs('onnxruntime') tmp_ret = collect_all('msvc-runtime') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] diff --git a/analyzer/ProjectKestrel.spec b/analyzer/ProjectKestrel.spec index 47ffd8cf..d035db9f 100644 --- a/analyzer/ProjectKestrel.spec +++ b/analyzer/ProjectKestrel.spec @@ -1,11 +1,22 @@ # -*- mode: python ; coding: utf-8 -*- +import os from PyInstaller.utils.hooks import collect_dynamic_libs from PyInstaller.utils.hooks import collect_all # models/ includes bundled SpeciesNet (models/speciesnet/info.json + weights) and SAM-HQ checkpoints. -datas = [('models', 'models'), ('kestrel_telemetry.py', '.'), ('folder_inspector.py', '.'), ('cli.py', '.'), ('VERSION.txt', '.'), ('kestrel_analyzer', 'kestrel_analyzer'), ('visualizer.html', '.'), ('visualizer.css', '.'), ('visualizer.js', '.'), ('csv_parser.js', '.'), ('culling.html', '.'), ('logo.png', '.'), ('logo.ico', '.'), ('sample_sets', 'sample_sets'), ('settings_utils.py', '.'), ('editor_launch.py', '.'), ('queue_manager.py', '.'), ('api_bridge.py', '.')] +datas = [('models', 'models'), ('kestrel_telemetry.py', '.'), ('build_attestation.py', '.'), ('folder_inspector.py', '.'), ('cli.py', '.'), ('VERSION.txt', '.'), ('kestrel_analyzer', 'kestrel_analyzer'), ('visualizer.html', '.'), ('css', 'css'), ('visualizer.js', '.'), ('csv_parser.js', '.'), ('culling.html', '.'), ('logo.png', '.'), ('logo.ico', '.'), ('sample_sets', 'sample_sets'), ('settings_utils.py', '.'), ('editor_launch.py', '.'), ('queue_manager.py', '.'), ('api_bridge.py', '.')] + +# CI generates build_attestation.json with the HMAC attestation for official builds. +# Source builds and dev workflows skip generation, in which case the bundle just +# doesn't include the file and the client falls back to the legacy auth header. +if os.path.exists('build_attestation.json'): + datas.append(('build_attestation.json', '.')) + print('[spec] Including build_attestation.json (official build).') +else: + print('[spec] No build_attestation.json present (source/dev build).') + binaries = [] -hiddenimports = ['pywebview','PIL','exifread','settings_utils','editor_launch','queue_manager','api_bridge'] +hiddenimports = ['pywebview','PIL','exifread','settings_utils','editor_launch','queue_manager','api_bridge','build_attestation'] binaries += collect_dynamic_libs('onnxruntime') tmp_ret = collect_all('msvc-runtime') datas += tmp_ret[0]; binaries += tmp_ret[1]; hiddenimports += tmp_ret[2] diff --git a/analyzer/VERSION.txt b/analyzer/VERSION.txt index 5c2e8e3b..17832cf1 100644 --- a/analyzer/VERSION.txt +++ b/analyzer/VERSION.txt @@ -1 +1 @@ -Gambel's Quail (Plume Fix) \ No newline at end of file +Nelson's Sparrow \ No newline at end of file diff --git a/analyzer/api_bridge.py b/analyzer/api_bridge.py index fd635464..d9138bad 100644 --- a/analyzer/api_bridge.py +++ b/analyzer/api_bridge.py @@ -16,7 +16,7 @@ import sys import webbrowser -from settings_utils import load_persisted_settings, save_persisted_settings, log +from settings_utils import load_persisted_settings, save_persisted_settings, debug, info, warn, error from queue_manager import _queue_manager try: @@ -42,14 +42,10 @@ def _preserve_highlights_for_stops(stops: float) -> float: except ImportError: _launch_editor = None -try: - from kestrel_analyzer.config import JPEG_EXTENSIONS as _JPEG_EXTENSIONS, RAW_EXTENSIONS as _RAW_EXTENSIONS -except ImportError: - try: - from analyzer.kestrel_analyzer.config import JPEG_EXTENSIONS as _JPEG_EXTENSIONS, RAW_EXTENSIONS as _RAW_EXTENSIONS - except ImportError: - _JPEG_EXTENSIONS = ['.jpg', '.jpeg', '.png', '.tif', '.tiff'] - _RAW_EXTENSIONS = ['.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.srw'] +from kestrel_analyzer.config import ( + JPEG_EXTENSIONS as _JPEG_EXTENSIONS, + RAW_EXTENSIONS as _RAW_EXTENSIONS, +) # Telemetry — failsafe import (never blocks startup) try: @@ -86,10 +82,9 @@ def _preserve_highlights_for_stops(stops: float) -> float: 'acdsee', 'paintshop', 'faststone', 'xnview', 'irfanview', 'custom', } -_DEFAULT_EDITOR_EXTENSIONS = [ - '.cr3', '.cr2', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.sr2', - '.jpg', '.jpeg', '.png', '.tif', '.tiff' -] +# Editor-launch allowlist tracks the analyzer's supported formats so any +# file Kestrel can analyze can also be opened in the configured editor. +_DEFAULT_EDITOR_EXTENSIONS = list(_RAW_EXTENSIONS) + list(_JPEG_EXTENSIONS) _EXTERNAL_URL_SCHEME_ALLOWLIST = frozenset({'http', 'https', 'mailto'}) @@ -130,13 +125,6 @@ def _is_safe_external_url(url) -> bool: _ALLOWED_EDITOR_EXTENSIONS: set[str] = set() -# ``KESTREL_ALLOW_ANY_EXTENSION`` was previously an env-var escape hatch that -# let users bypass the extension allowlist on the ``open_in_editor`` surface. -# That's a FINDING-07 escape hatch and is now hard-off: the set of allowed -# extensions is fixed to ``_DEFAULT_EDITOR_EXTENSIONS`` plus the user's -# ``KESTREL_ALLOWED_EXTENSIONS`` override (still validated), and the check is -# never bypassed. -_ALLOW_ANY_EDITOR_EXTENSION = False def _normalize_extensions(exts): @@ -190,6 +178,10 @@ def __init__(self): self._has_unsaved_changes: bool = False self._cache_cleanup_roots: set[str] = set() self._culling_companion_extensions: tuple[str, ...] = _CULLING_COMPANION_EXTENSIONS + # Set externally by visualizer.main() after window/server come up. + self._main_window = None + self._culling_window = None + self._server_port: int | None = None def notify_dirty(self, is_dirty: bool) -> dict: """Called from JS whenever the dirty flag changes.""" @@ -206,9 +198,9 @@ def report_js_error(self, error_data: dict) -> dict: stack = str(error_data.get('stack', ''))[:1500] source = str(error_data.get('source', '')) line = error_data.get('line', '') - log(f'[JS {err_type}] {msg}' + (f' @ {source}:{line}' if source else '')) + warn(f'[JS {err_type}] {msg}' + (f' @ {source}:{line}' if source else '')) if stack: - log(f'[JS {err_type} stack]\n{stack}') + warn(f'[JS {err_type} stack]\n{stack}') except Exception: pass return {'success': True} @@ -290,8 +282,6 @@ def _is_within_root(self, path: str, root: str) -> bool: return False def _editor_extension_allowed(self, path: str) -> bool: - if _ALLOW_ANY_EDITOR_EXTENSION: - return True _, ext = os.path.splitext(path) return ext.lower() in _ALLOWED_EDITOR_EXTENSIONS @@ -312,7 +302,7 @@ def _log_security_reject(self, context: str, reason: str, **details) -> None: txt = txt[:300] + '...' parts.append(f'{key}={txt!r}') suffix = f' ({", ".join(parts)})' if parts else '' - log(f'[security] Reject {context}: {reason}{suffix}') + warn(f'[security] Reject {context}: {reason}{suffix}') except Exception: pass @@ -456,7 +446,7 @@ def _fetch_remote_legal_payload(self) -> dict: 'privacy_url': str(data.get('privacy_url', '') or '').strip(), } except Exception as e: - log(f'[legal] fetch_remote_legal failed: {e}') + warn(f'[legal] fetch_remote_legal failed: {e}') return {} def fetch_remote_legal(self): @@ -497,7 +487,7 @@ def get_legal_status(self) -> dict: if not effective_date: agreed = legacy_agreed reason = None if agreed else 'new_user' - log(f'[legal] get_legal_status (offline fallback): agreed={agreed}') + info(f'[legal] get_legal_status (offline fallback): agreed={agreed}') return { 'agreed': agreed, 'reason': reason, @@ -517,7 +507,7 @@ def get_legal_status(self) -> dict: agreed = False reason = 'new_user' - log( + info( f'[legal] get_legal_status: agreed={agreed}, reason={reason}, ' f'stored_date={stored_date!r}, effective_date={effective_date!r}' ) @@ -547,14 +537,14 @@ def agree_to_legal(self, effective_date: str = ''): date_str = str(effective_date or '').strip() if date_str: settings['legal_agreed_date'] = date_str - log(f'[legal] User agreed to terms (version {version}, effective_date={date_str!r})') + info(f'[legal] User agreed to terms (version {version}, effective_date={date_str!r})') if not settings.get('installed_telemetry_sent', False): if _telemetry: mid = _telemetry.get_machine_id(settings) _telemetry.send_installation_telemetry(mid, version=version) settings['installed_telemetry_sent'] = True - log('[legal] Initial installation telemetry triggered.') + info('[legal] Initial installation telemetry triggered.') save_persisted_settings(settings) return {'success': True} @@ -563,7 +553,6 @@ def choose_directory(self): """Open native folder picker dialog. Returns: absolute path to selected folder, or None if cancelled. """ - print(f"[API] choose_directory() called (platform: {sys.platform})", flush=True) try: if sys.platform == 'darwin': script = 'POSIX path of (choose folder with prompt "Select folder containing analyzed photos")' @@ -573,27 +562,9 @@ def choose_directory(self): text=True, timeout=120 ) - if result.returncode == 0 and result.stdout.strip(): - selected_path = result.stdout.strip() - print(f"[API] choose_directory() -> Success: {selected_path}", flush=True) - return selected_path - print("[API] choose_directory() -> Cancelled by user", flush=True) - return None - elif sys.platform.startswith('win'): - import tkinter as tk - from tkinter import filedialog - root = tk.Tk() - root.withdraw() - root.attributes('-topmost', True) - folder = filedialog.askdirectory(title="Select folder containing analyzed photos") - root.destroy() - if folder: - print(f"[API] choose_directory() -> Success: {folder}", flush=True) - return folder - else: - print("[API] choose_directory() -> Cancelled by user", flush=True) - return None + folder = result.stdout.strip() if result.returncode == 0 else '' else: + # tkinter filedialog works on both Windows and Linux import tkinter as tk from tkinter import filedialog root = tk.Tk() @@ -601,15 +572,11 @@ def choose_directory(self): root.attributes('-topmost', True) folder = filedialog.askdirectory(title="Select folder containing analyzed photos") root.destroy() - if folder: - print(f"[API] choose_directory() -> Success: {folder}", flush=True) - return folder - else: - print("[API] choose_directory() -> Cancelled by user", flush=True) - return None + + info(f'[API] choose_directory -> {folder!r}' if folder else '[API] choose_directory -> cancelled') + return folder or None except Exception as e: - print(f"[API] choose_directory() -> Error: {e}", flush=True) - log(f"Error in choose_directory: {e}") + error(f'[API] choose_directory error: {e}') return None def open_file_explorer(self, folder_path): @@ -631,7 +598,7 @@ def open_file_explorer(self, folder_path): subprocess.run(['xdg-open', root_real], check=False) return {'success': True, 'path': root_real} except Exception as e: - print(f"[API] open_file_explorer error: {e}", flush=True) + error(f'[API] open_file_explorer error: {e}') return {'success': False, 'error': str(e)} def choose_application(self): @@ -663,7 +630,7 @@ def choose_application(self): root.destroy() return filepath if filepath else None except Exception as e: - print(f"[API] choose_application() -> Error: {e}", flush=True) + error(f'[API] choose_application error: {e}') return None def read_kestrel_csv(self, folder_path): @@ -714,7 +681,7 @@ def read_kestrel_csv(self, folder_path): 'root': parent_folder } except Exception as e: - print(f"[API] read_kestrel_csv() -> Error: {e}", flush=True) + error(f'[API] read_kestrel_csv error: {e}') return { 'success': False, 'error': str(e), @@ -757,10 +724,10 @@ def clear_kestrel_data(self, folder_path: str): return {'success': True, 'message': 'No .kestrel folder found'} shutil.rmtree(kestrel_dir) - print(f"[API] clear_kestrel_data() -> Removed .kestrel from {kestrel_dir}", flush=True) + info(f'[API] clear_kestrel_data: removed {kestrel_dir}') return {'success': True, 'message': 'Kestrel analysis data cleared'} except Exception as e: - print(f"[API] clear_kestrel_data() -> Error: {e}", flush=True) + error(f'[API] clear_kestrel_data error: {e}') return {'success': False, 'error': str(e)} def is_frozen_app(self): @@ -779,6 +746,83 @@ def get_app_version(self): except Exception: return {'success': True, 'version': 'unknown'} + def report_bridge_ready(self): + """Diagnostic endpoint for --api-probe mode. + + Called from JS on the ``pywebviewready`` event to prove the JS-Python + bridge round-trips. Safe to call at any time; side-effect-free unless a + probe is listening (when ``self._probe_ready_event`` is set, this stores + the payload on ``self._probe_ready_payload`` and signals the event). + """ + from datetime import datetime, timezone + try: + from kestrel_analyzer.config import VERSION + except Exception: + try: + from analyzer.kestrel_analyzer.config import VERSION + except Exception: + VERSION = 'unknown' + payload = { + 'ok': True, + 'version': VERSION, + 'frozen': bool(getattr(sys, 'frozen', False)), + 'timestamp': datetime.now(timezone.utc).isoformat(), + } + evt = getattr(self, '_probe_ready_event', None) + if evt is not None: + self._probe_ready_payload = payload + try: + evt.set() + except Exception: + pass + return payload + + def get_species_family_map(self): + """Return a {species_display_name: family_display_name} mapping for the + bird species classifier's North American taxonomy. + + Joins ``labels_scispecies.csv`` (Species → Scientific Family) with + ``scispecies_dispname.csv`` (Scientific Family → Display Name) and + caches the result on the bridge instance. Used by the frontend to + auto-link species/family chips and populate species autocomplete. + """ + cached = getattr(self, '_species_family_map_cache', None) + if cached is not None: + return cached + try: + import csv + try: + from kestrel_analyzer.config import MODELS_DIR as _models_dir + except ImportError: + from analyzer.kestrel_analyzer.config import MODELS_DIR as _models_dir + base = str(_models_dir) + species_to_scifam: dict[str, str] = {} + with open(os.path.join(base, 'labels_scispecies.csv'), 'r', encoding='utf-8-sig', newline='') as f: + reader = csv.DictReader(f) + for row in reader: + sp = (row.get('Species') or '').strip() + fam = (row.get('Scientific Family') or '').strip() + if sp and fam: + species_to_scifam[sp] = fam + scifam_to_display: dict[str, str] = {} + with open(os.path.join(base, 'scispecies_dispname.csv'), 'r', encoding='utf-8-sig', newline='') as f: + reader = csv.DictReader(f) + for row in reader: + sci = (row.get('Scientific Family') or '').strip() + disp = (row.get('Display Name') or '').strip() + if sci and disp: + scifam_to_display[sci] = disp + mapping: dict[str, str] = {} + for sp, sci in species_to_scifam.items(): + disp = scifam_to_display.get(sci, sci) + mapping[sp] = disp + result = {'success': True, 'map': mapping} + except Exception as e: + error(f'[API] get_species_family_map error: {e}') + result = {'success': False, 'error': str(e), 'map': {}} + self._species_family_map_cache = result + return result + def fetch_remote_version(self): """Fetch version.json from projectkestrel.org to bypass CORS in JS.""" try: @@ -801,7 +845,7 @@ def fetch_remote_version(self): data = json.loads(resp.read().decode('utf-8')) return {'success': True, 'data': data} except Exception as e: - print(f"[API] fetch_remote_version() -> Error: {e}", flush=True) + error(f'[API] fetch_remote_version error: {e}') return {'success': False, 'error': str(e)} def get_platform_info(self): @@ -849,7 +893,7 @@ def inspect_folder(self, folder_path: str): info = inspector.inspect_folder(folder_real) return {'success': True, 'info': info} except Exception as e: - print(f"[API] inspect_folder() -> Error: {e}", flush=True) + error(f'[API] inspect_folder error: {e}') return {'success': False, 'error': str(e)} def inspect_folders(self, paths): @@ -896,7 +940,7 @@ def inspect_folders(self, paths): results = inspector.inspect_folders(validated_paths) return {'success': True, 'results': results} except Exception as e: - print(f"[API] inspect_folders() -> Error: {e}", flush=True) + error(f'[API] inspect_folders error: {e}') return {'success': False, 'error': str(e), 'results': {}} def read_image_file(self, relative_path, root_path): @@ -937,7 +981,7 @@ def read_image_file(self, relative_path, root_path): 'error': '' } except Exception as e: - print(f"[API] read_image_file() -> Error: {e}", flush=True) + error(f'[API] read_image_file error: {e}') return {'success': False, 'error': str(e), 'data': '', 'mime': ''} def list_subfolders(self, root_path: str, max_depth: int = 3): @@ -1025,7 +1069,7 @@ def _scan(dir_path: str, depth: int) -> list: 'truncated': bool(limit_reached[0]), } except Exception as e: - print(f"[API] list_subfolders() -> Error: {e}", flush=True) + error(f'[API] list_subfolders error: {e}') return {'success': False, 'tree': [], 'error': str(e)} def write_kestrel_csv(self, folder_path: str, csv_content: str): @@ -1042,11 +1086,11 @@ def write_kestrel_csv(self, folder_path: str, csv_content: str): csv_path = os.path.join(kestrel_dir, 'kestrel_database.csv') if not os.path.exists(csv_path): return {'success': False, 'error': f'CSV not found: {csv_path}'} - with open(csv_path, 'w', encoding='utf-8', newline='') as f: + with open(csv_path, 'w', encoding='utf-8-sig', newline='') as f: f.write(csv_content) return {'success': True, 'path': csv_path} except Exception as e: - print(f'[API] write_kestrel_csv({folder_path!r}) -> Error: {e}', flush=True) + error(f'[API] write_kestrel_csv({folder_path!r}) error: {e}') return {'success': False, 'error': str(e)} def apply_normalization(self, folder_path: str, mode: str = None) -> dict: @@ -1075,13 +1119,11 @@ def apply_normalization(self, folder_path: str, mode: str = None) -> dict: try: from kestrel_analyzer.ratings import ( - compute_quality_distribution, get_profile_thresholds, quality_to_rating, ) except ImportError: from analyzer.kestrel_analyzer.ratings import ( - compute_quality_distribution, get_profile_thresholds, quality_to_rating, ) @@ -1095,7 +1137,6 @@ def apply_normalization(self, folder_path: str, mode: str = None) -> dict: return {'success': False, 'error': err, 'normalized_ratings': {}, 'mode_used': ''} csv_path = os.path.join(kestrel_dir, 'kestrel_database.csv') - metadata_path = os.path.join(kestrel_dir, 'kestrel_metadata.json') if not os.path.exists(csv_path): return {'success': False, 'error': 'No database found', 'normalized_ratings': {}, 'mode_used': ''} @@ -1108,26 +1149,6 @@ def apply_normalization(self, folder_path: str, mode: str = None) -> dict: if df.empty: return {'success': True, 'normalized_ratings': {}, 'mode_used': profile, 'error': ''} - # --- Cache per-folder quality distribution (for potential histogram display) --- - quality_scores = df['quality'].tolist() if 'quality' in df.columns else [] - folder_dist = compute_quality_distribution(quality_scores) - - try: - _meta = {} - if os.path.exists(metadata_path): - with open(metadata_path, 'r', encoding='utf-8') as mf: - content = mf.read().strip() - if content: - loaded = json.loads(content) - if isinstance(loaded, dict): - _meta = loaded - _meta['quality_distribution'] = folder_dist - _meta['quality_distribution_stored'] = True - with open(metadata_path, 'w', encoding='utf-8') as mf: - json.dump(_meta, mf, indent=2) - except Exception: - pass - # --- Map quality scores to star ratings (in memory only — no CSV write) --- if 'filename' not in df.columns or 'quality' not in df.columns: return {'success': True, 'normalized_ratings': {}, 'mode_used': profile, 'error': ''} @@ -1150,7 +1171,7 @@ def _get_rating(q_val): 'error': '', } except Exception as e: - print(f'[API] apply_normalization() -> Error: {e}', flush=True) + error(f'[API] apply_normalization error: {e}') return {'success': False, 'error': str(e), 'normalized_ratings': {}, 'mode_used': ''} def read_kestrel_scenedata(self, folder_path: str) -> dict: @@ -1185,7 +1206,7 @@ def read_kestrel_scenedata(self, folder_path: str) -> dict: return {'success': True, 'data': data, 'error': ''} except Exception as e: - print(f'[API] read_kestrel_scenedata({folder_path!r}) -> Error: {e}', flush=True) + error(f'[API] read_kestrel_scenedata({folder_path!r}) error: {e}') return {'success': False, 'data': {}, 'error': str(e)} def write_kestrel_scenedata(self, folder_path: str, scenedata: dict) -> dict: @@ -1218,7 +1239,7 @@ def write_kestrel_scenedata(self, folder_path: str, scenedata: dict) -> dict: json.dump(scenedata, f, indent=2) return {'success': True, 'path': scenedata_path, 'error': ''} except Exception as e: - print(f'[API] write_kestrel_scenedata({folder_path!r}) -> Error: {e}', flush=True) + error(f'[API] write_kestrel_scenedata({folder_path!r}) error: {e}') return {'success': False, 'error': str(e), 'path': ''} def open_folder(self, path: str): @@ -1238,7 +1259,7 @@ def open_folder(self, path: str): subprocess.Popen(['xdg-open', path]) return {'success': True} except Exception as e: - print(f'[API] open_folder({path!r}) -> Error: {e}', flush=True) + error(f'[API] open_folder({path!r}) error: {e}') return {'success': False, 'error': str(e)} def open_in_editor(self, root: str, relative: str, editor: str = 'system'): @@ -1269,7 +1290,7 @@ def open_in_editor(self, root: str, relative: str, editor: str = 'system'): _launch_editor(target, editor_name) return {'success': True, 'path': target} except Exception as e: - print(f'[API] open_in_editor() -> Error: {e}', flush=True) + error(f'[API] open_in_editor error: {e}') return {'success': False, 'error': str(e)} def open_url(self, url: str): @@ -1282,12 +1303,12 @@ def open_url(self, url: str): """ try: if not _is_safe_external_url(url): - log(f'[security] open_url refused unsafe URL: {url!r}') + warn(f'[security] open_url refused unsafe URL: {url!r}') return {'success': False, 'error': 'URL scheme not allowed'} webbrowser.open(url) return {'success': True} except Exception as e: - print(f'[API] open_url({url!r}) -> Error: {e}', flush=True) + error(f'[API] open_url({url!r}) error: {e}') return {'success': False, 'error': str(e)} # ------------------------------------------------------------------ # @@ -1298,7 +1319,7 @@ def send_feedback(self, data): """Send feedback / bug report (async, failsafe). Called from JS.""" try: if _telemetry is None: - print('[API] send_feedback() -> telemetry unavailable', flush=True) + warn('[API] send_feedback: telemetry unavailable') return {'success': False, 'error': 'Telemetry module not available'} if not isinstance(data, dict): return {'success': False, 'error': 'Invalid data'} @@ -1319,7 +1340,7 @@ def send_feedback(self, data): ) return {'success': True} except Exception as e: - print(f'[API] send_feedback() -> Error: {e}', flush=True) + error(f'[API] send_feedback error: {e}') return {'success': False, 'error': str(e)} def get_settings(self): @@ -1333,7 +1354,7 @@ def get_settings(self): save_persisted_settings(settings) return {'success': True, 'settings': settings} except Exception as e: - print(f'[API] get_settings() -> Error: {e}', flush=True) + error(f'[API] get_settings error: {e}') return {'success': False, 'error': str(e), 'settings': {}} def save_settings_data(self, settings_dict): @@ -1369,7 +1390,7 @@ def _coerce_number(v): save_persisted_settings(merged) return {'success': True} except Exception as e: - print(f'[API] save_settings_data() -> Error: {e}', flush=True) + error(f'[API] save_settings_data error: {e}') return {'success': False, 'error': str(e)} # ------------------------------------------------------------------ # @@ -1494,9 +1515,10 @@ def get_sample_sets_paths(self): if not candidates: error_msg = 'sample_sets folder not found' + # Dump the full path-search trace on failure so users can diagnose. for line in debug_info: - print(line, flush=True) - print(f'[API] get_sample_sets_paths() -> Error: {error_msg}', flush=True) + warn(line) + error(f'[API] get_sample_sets_paths: {error_msg}') return {'success': False, 'error': error_msg, 'paths': []} sample_root = candidates[0] @@ -1535,22 +1557,32 @@ def get_sample_sets_paths(self): paths.append(full) debug_info.append(f'[api] Added path: {full}') + # Success path: one-line summary at INFO. Full trace only at DEBUG. for line in debug_info: - print(line, flush=True) - print(f'[API] get_sample_sets_paths() -> {len(paths)} sets from {sample_root}', flush=True) + debug(line) + info(f'[API] get_sample_sets_paths: {len(paths)} sets from {sample_root}') return {'success': True, 'paths': paths} except Exception as e: import traceback - print(f'[API] get_sample_sets_paths() -> Error: {e}', flush=True) - print(f'[API] Traceback: {traceback.format_exc()}', flush=True) + error(f'[API] get_sample_sets_paths error: {e}') + error(f'[API] Traceback: {traceback.format_exc()}') return {'success': False, 'error': str(e), 'paths': []} # ------------------------------------------------------------------ # # Analysis Queue API (called from JavaScript in pywebview mode) # # ------------------------------------------------------------------ # - def start_analysis_queue(self, paths, use_gpu=True, wildlife_enabled=True): - """Enqueue folders for analysis. ``paths`` may be a JSON string or list.""" + def start_analysis_queue(self, paths, use_gpu=True, wildlife_enabled=True, retry_errored=False, species_detection_enabled=True): + """Enqueue folders for analysis. ``paths`` may be a JSON string or list. + + ``retry_errored`` (bool): when True, drop rows previously marked + ``species == "Error"`` from each folder's CSV before reprocessing, so + those images get re-analyzed instead of being skipped as already-done. + + ``species_detection_enabled`` (bool): when False, the bird species + classifier is skipped and species/family fields are recorded as + ``Unknown``. Detection, quality scoring, and culling still run. + """ try: if isinstance(paths, str): paths = json.loads(paths) @@ -1593,10 +1625,16 @@ def start_analysis_queue(self, paths, use_gpu=True, wildlife_enabled=True): if mode_raw == 'accurate': detector_name = 'mdv5a' elif mode_raw == 'fast': - detector_name = 'mdv6-e' + detector_name = 'mdv1000-cedar' else: - legacy_detector = str(sett.get('detector_name', '') or '').strip().lower() - if legacy_detector in {'mdv5a', 'mdv6-e'}: + # Belt-and-braces: settings_utils._migrate_legacy_detector_name + # has already remapped 'mdv6-e' on load, but if a raw stored value + # still gets here we accept it and migrate again. + from settings_utils import _migrate_legacy_detector_name + legacy_detector = _migrate_legacy_detector_name( + str(sett.get('detector_name', '') or '').strip().lower() + ) + if legacy_detector in {'mdv5a', 'mdv1000-cedar'}: detector_name = legacy_detector mask_threshold = float(sett.get('mask_threshold', 0.5)) mask_threshold = max(0.5, min(0.95, mask_threshold)) @@ -1612,14 +1650,16 @@ def start_analysis_queue(self, paths, use_gpu=True, wildlife_enabled=True): parallel_prefetch = max(1, min(5, parallel_prefetch)) return _queue_manager.enqueue(validated_paths, use_gpu=bool(use_gpu), wildlife_enabled=bool(wildlife_enabled), + species_detection_enabled=bool(species_detection_enabled), detection_threshold=detection_threshold, scene_time_threshold=scene_time_threshold, mask_threshold=mask_threshold, max_bird_crops=max_bird_crops, parallel_prefetch=parallel_prefetch, - detector_name=detector_name) + detector_name=detector_name, + retry_errored=bool(retry_errored)) except Exception as e: - print(f'[API] start_analysis_queue() -> Error: {e}', flush=True) + error(f'[API] start_analysis_queue error: {e}') return {'success': False, 'error': str(e)} def pause_analysis_queue(self): @@ -1665,15 +1705,26 @@ def is_analysis_running(self): return {'running': _queue_manager.is_running} def get_recovery_status(self): - """Return persisted queue-recovery and unclean-shutdown state.""" + """Return persisted queue-recovery and unclean-shutdown state. + + ``exit_reason`` is the classified outcome of the previous session + (``'clean' | 'os_shutdown' | 'crash' | 'unknown'``). The frontend + uses it to pick dialog wording — alarming for ``'crash'``, soft + for ``'unknown'``, no dialog at all for the other two. See + ``visualizer._classify_prior_session``. + """ try: settings = load_persisted_settings() queue_state = _queue_manager.get_persisted_recovery_state() unclean_utc = str(settings.get('last_unclean_shutdown_utc', '') or '').strip() + exit_reason = str(settings.get('last_exit_reason', '') or '').strip().lower() + if exit_reason not in ('clean', 'os_shutdown', 'crash', 'unknown'): + exit_reason = 'unknown' if unclean_utc else 'clean' return { 'success': True, 'unclean_shutdown': bool(unclean_utc), 'unclean_shutdown_utc': unclean_utc, + 'exit_reason': exit_reason, 'queue_recovery': queue_state, } except Exception as e: @@ -1704,6 +1755,7 @@ def send_recovery_crash_report(self): machine_id = _telemetry.get_machine_id(settings) active_folder = str(settings.get('active_analysis_path', '') or '').strip() log_tail = _telemetry.get_recent_log_tail(folder=active_folder or None, runtime_log_files=3) + exit_reason = str(settings.get('last_exit_reason', '') or '').strip().lower() or 'unknown' _telemetry.send_crash_report( exc=None, tb_str='Recovered unclean shutdown report requested by user.', @@ -1711,9 +1763,11 @@ def send_recovery_crash_report(self): session_analytics={ 'recovery_report': True, 'active_analysis_path': active_folder, + 'exit_reason': exit_reason, }, machine_id=machine_id, version=_telemetry._read_version(), + exit_reason=exit_reason, ) return {'success': True} except Exception as e: @@ -1723,10 +1777,6 @@ def send_recovery_crash_report(self): # Culling Assistant API # # ------------------------------------------------------------------ # - _main_window = None - _culling_window = None - _server_port = None - def open_culling_window(self, root_path: str): """Open a new pywebview window for the Culling Assistant.""" try: @@ -1742,12 +1792,7 @@ def open_culling_window(self, root_path: str): port = self._server_port or 8765 from urllib.parse import quote culling_url = f'http://{HOST}:{port}/culling.html?root={quote(root_real, safe="")}' - - methods = [m for m in dir(self) if not m.startswith('_') and callable(getattr(self, m))] - log(f'[culling] Creating window with Api instance') - log(f'[culling] Available public methods (first 10): {methods[:10]}') - log(f'[culling] read_kestrel_csv available: {"read_kestrel_csv" in methods}') - + win = _wv.create_window( f'Culling Assistant \u2014 {folder_name}', culling_url, @@ -1756,12 +1801,11 @@ def open_culling_window(self, root_path: str): height=900, ) self._culling_window = win - log(f'[culling] Culling window created successfully') return {'success': True} except Exception as e: - log(f'open_culling_window error: {e}') + error(f'[API] open_culling_window error: {e}') import traceback - log(f'[culling] Traceback: {traceback.format_exc()}') + error(f'[culling] Traceback: {traceback.format_exc()}') return {'success': False, 'error': str(e)} def _find_sidecar_file(self, root_path: str, filename: str, ext: str = '.xmp'): @@ -1835,12 +1879,12 @@ def _move_file_with_sidecars(self, root_path: str, filename: str, reject_dir: st shutil.move(companion_src, companion_dst) moved_files.append(companion) else: - log(f'move_rejects: Warning - companion detected but not found at: {companion_src}') + warn(f'[reject] companion detected but not found at: {companion_src}') except Exception as e: # Log warning but don't fail the main move if a companion fails - log(f'move_rejects: Warning - Failed to move {companion}: {e}') + warn(f'[reject] Failed to move {companion}: {e}') else: - log(f'move_rejects: No companion sidecars found for: {filename}') + debug(f'[reject] No companion sidecars found for: {filename}') return True, moved_files @@ -1883,10 +1927,10 @@ def move_rejects_to_folder(self, root_path: str, filenames): moved.extend(moved_files) else: errors.append(f'{fn}: move failed') - log(f'move_rejects: moved {len(moved)} file(s) (including sidecars), errors {len(errors)}') + info(f'[reject] moved {len(moved)} file(s) (including sidecars), errors {len(errors)}') return {'success': True, 'moved': len(moved), 'errors': errors, 'reject_folder': reject_real} except Exception as e: - log(f'move_rejects_to_folder error: {e}') + error(f'[API] move_rejects_to_folder error: {e}') return {'success': False, 'error': str(e)} def write_xmp_metadata( @@ -1945,9 +1989,9 @@ def _restore_file_with_sidecars(self, reject_dir: str, root_path: str, filename: restored_files.append(companion) except Exception as e: # Log warning but don't fail if companion restore fails - log(f'undo_reject_move: Warning - Failed to restore {companion}: {e}') + warn(f'[reject-undo] Failed to restore {companion}: {e}') else: - log(f'undo_reject_move: No companion sidecars found for: {filename}') + debug(f'[reject-undo] No companion sidecars found for: {filename}') return True, restored_files @@ -1992,10 +2036,10 @@ def undo_reject_move(self, root_path: str, filenames): restored.extend(restored_files) else: errors.append(f"{fn}: not found in rejects") - log(f"undo_reject_move: restored {len(restored)} file(s) (including sidecars), errors {len(errors)}") + info(f"[reject-undo] restored {len(restored)} file(s) (including sidecars), errors {len(errors)}") return {"success": True, "restored": len(restored), "errors": errors} except Exception as e: - log(f"undo_reject_move error: {e}") + error(f"[API] undo_reject_move error: {e}") return {"success": False, "error": str(e)} def get_reject_restore_state(self, root_path: str): @@ -2061,17 +2105,9 @@ def get_reject_restore_state(self, root_path: str): 'has_scenedata_backup': has_scenedata_backup, } except Exception as e: - log(f'get_reject_restore_state error: {e}') + error(f'[API] get_reject_restore_state error: {e}') return {'success': False, 'error': str(e)} - def backup_kestrel_csv(self, root_path: str): - """Copy kestrel_database.csv to kestrel_database_old.csv as backup. - - Deprecated: Use backup_kestrel_db instead for dual backup. - Kept for backward compatibility. - """ - return self.backup_kestrel_db(root_path) - def backup_kestrel_db(self, root_path: str): """Backup both kestrel_database.csv and kestrel_scenedata.json before major operations. @@ -2103,14 +2139,14 @@ def backup_kestrel_db(self, root_path: str): # Backup CSV shutil.copy2(csv_path, csv_backup) - log(f"backup_kestrel_db: CSV backed up to {csv_backup}") + info(f"[backup] CSV backed up to {csv_backup}") # Backup scenedata if it exists scenedata_backed = False if os.path.exists(scenedata_path): shutil.copy2(scenedata_path, scenedata_backup) scenedata_backed = True - log(f"backup_kestrel_db: Scenedata backed up to {scenedata_backup}") + info(f"[backup] Scenedata backed up to {scenedata_backup}") return { "success": True, @@ -2119,17 +2155,9 @@ def backup_kestrel_db(self, root_path: str): "error": "" } except Exception as e: - log(f"backup_kestrel_db error: {e}") + error(f"[API] backup_kestrel_db error: {e}") return {"success": False, "error": str(e), "backup_csv": "", "backup_scenedata": ""} - def restore_kestrel_csv_backup(self, root_path: str): - """Restore kestrel_database_old.csv back to kestrel_database.csv. - - Deprecated: Use restore_kestrel_db_backup instead for dual restore. - Kept for backward compatibility. - """ - return self.restore_kestrel_db_backup(root_path) - def restore_kestrel_db_backup(self, root_path: str): """Restore both kestrel_database.csv and kestrel_scenedata.json from backups. @@ -2161,16 +2189,16 @@ def restore_kestrel_db_backup(self, root_path: str): # Restore CSV shutil.copy2(csv_backup, csv_path) - log(f"restore_kestrel_db_backup: CSV restored from {csv_backup}") + info(f"[backup] CSV restored from {csv_backup}") # Restore scenedata if backup exists if os.path.exists(scenedata_backup): shutil.copy2(scenedata_backup, scenedata_path) - log(f"restore_kestrel_db_backup: Scenedata restored from {scenedata_backup}") + info(f"[backup] Scenedata restored from {scenedata_backup}") return {"success": True, "error": ""} except Exception as e: - log(f"restore_kestrel_db_backup error: {e}") + error(f"[API] restore_kestrel_db_backup error: {e}") return {"success": False, "error": str(e)} def open_reject_folder(self, root_path: str): @@ -2201,7 +2229,7 @@ def notify_main_window_refresh(self): return {'success': True} return {'success': False, 'error': 'No main window found'} except Exception as e: - log(f'notify_main_window_refresh error: {e}') + error(f'[API] notify_main_window_refresh error: {e}') return {'success': False, 'error': str(e)} def read_raw_full( @@ -2247,10 +2275,9 @@ def read_raw_full( if not os.path.exists(full_path): return {'success': False, 'error': f'File not found: {filename}'} - raw_extensions = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.srw'} ext = os.path.splitext(filename)[1].lower() - if ext not in raw_extensions: + if ext not in _RAW_EXTENSION_SET: return self.read_image_file(filename, root_path_real) # Clamp exposure correction to the same limits as the pipeline @@ -2325,8 +2352,8 @@ def read_raw_full( } if use_cache and os.path.exists(cache_path): - log( - f'read_raw_full: Cache hit for {filename} ' + debug( + f'[raw-preview] cache hit for {filename} ' f'(exp={exp_correction:+.3f}, mode={render_mode})' ) with open(cache_path, 'rb') as f: @@ -2339,15 +2366,15 @@ def read_raw_full( 'storage_preview_path': cache_path, }) if debug_logging_enabled: - log(f'read_raw_full debug: {json.dumps(debug_meta, sort_keys=True)}') + debug(f'[raw-preview] debug: {json.dumps(debug_meta, sort_keys=True)}') b64 = base64.b64encode(cache_bytes).decode('ascii') return {'success': True, 'data': b64, 'mime': 'image/jpeg', 'debug': debug_meta} import rawpy from PIL import Image - log( - f'read_raw_full: Processing RAW file {filename} ' + debug( + f'[raw-preview] Processing RAW file {filename} ' f'(exp={exp_correction:+.3f}, mode={render_mode}, cache={use_cache})' ) with rawpy.imread(full_path) as raw: @@ -2373,12 +2400,13 @@ def read_raw_full( exp_preserve_highlights=_preserve_highlights_for_stops(exp_correction), ) else: - rgb = raw.postprocess() if exp_correction != 0.0: rgb = raw.postprocess( exp_shift=linear_scale, exp_preserve_highlights=_preserve_highlights_for_stops(exp_correction), ) + else: + rgb = raw.postprocess() img = Image.fromarray(rgb) @@ -2414,14 +2442,14 @@ def read_raw_full( 'jpeg_dimensions': {'width': int(img.width), 'height': int(img.height)}, }) if debug_logging_enabled: - log(f'read_raw_full debug: {json.dumps(debug_meta, sort_keys=True)}') + debug(f'[raw-preview] debug: {json.dumps(debug_meta, sort_keys=True)}') if use_cache: - log(f'read_raw_full: Done, {len(jpg_bytes)//1024}KB JPEG ({img.width}x{img.height}), cached as {cache_name}') + debug(f'[raw-preview] Done, {len(jpg_bytes)//1024}KB JPEG ({img.width}x{img.height}), cached as {cache_name}') else: - log(f'read_raw_full: Done, {len(jpg_bytes)//1024}KB JPEG ({img.width}x{img.height}), cache disabled') + debug(f'[raw-preview] Done, {len(jpg_bytes)//1024}KB JPEG ({img.width}x{img.height}), cache disabled') return {'success': True, 'data': b64, 'mime': 'image/jpeg', 'debug': debug_meta} except Exception as e: - log(f'read_raw_full error: {e} (filename={filename}, root_path={root_path_real if "root_path_real" in locals() else root_path})') + error(f'[API] read_raw_full error: {e} (filename={filename}, root_path={root_path_real if "root_path_real" in locals() else root_path})') return {'success': False, 'error': str(e)} def cleanup_culling_cache(self, root_path: str): @@ -2442,11 +2470,11 @@ def cleanup_culling_cache(self, root_path: str): if os.path.exists(cache_dir): shutil.rmtree(cache_dir) - log(f'cleanup_culling_cache: Removed {cache_dir}') + info(f'[cache] cleanup_culling_cache: removed {cache_dir}') return {'success': True} return {'success': True} except Exception as e: - log(f'cleanup_culling_cache error: {e}') + error(f'[API] cleanup_culling_cache error: {e}') return {'success': False, 'error': str(e)} def cleanup_tracked_culling_caches(self): @@ -2469,5 +2497,5 @@ def cleanup_tracked_culling_caches(self): self._cache_cleanup_roots.clear() return {'success': len(failed) == 0, 'cleared': cleared, 'failed': failed} except Exception as e: - log(f'cleanup_tracked_culling_caches error: {e}') + error(f'[API] cleanup_tracked_culling_caches error: {e}') return {'success': False, 'cleared': 0, 'failed': [{'root': '', 'error': str(e)}]} diff --git a/analyzer/build_attestation.py b/analyzer/build_attestation.py new file mode 100644 index 00000000..0b7d7476 --- /dev/null +++ b/analyzer/build_attestation.py @@ -0,0 +1,74 @@ +""" +Project Kestrel — Build Attestation Loader + +Reads ``build_attestation.json`` if CI baked one into this build, and exposes +the HMAC headers needed for the worker's "official" auth tier. When the file +is absent (source builds, pre-attestation binaries, dev workflows) this module +returns an empty dict so the caller can fall back to the legacy X-Kestrel-Key. + +DESIGN RULES (same as kestrel_telemetry): + 1. Never raise — telemetry must stay fire-and-forget. + 2. No I/O after first load; results are cached at module scope. +""" + +import json +import os +import sys +from typing import Dict, List, Optional + +_loaded: bool = False +_meta: Optional[str] = None +_sig: Optional[str] = None + + +def _candidate_paths() -> List[str]: + """Locations where build_attestation.json may live, in priority order.""" + paths: List[str] = [] + meipass = getattr(sys, '_MEIPASS', None) + if meipass: + paths.append(os.path.join(meipass, 'build_attestation.json')) + here = os.path.dirname(os.path.abspath(__file__)) + paths.append(os.path.join(here, 'build_attestation.json')) + paths.append(os.path.join(here, '..', 'build_attestation.json')) + return paths + + +def _load() -> None: + global _loaded, _meta, _sig + if _loaded: + return + _loaded = True + for path in _candidate_paths(): + try: + if not os.path.isfile(path): + continue + with open(path, 'r', encoding='utf-8') as f: + data = json.load(f) + meta = data.get('meta') + sig = data.get('sig') + if isinstance(meta, str) and isinstance(sig, str) and meta and sig: + _meta = meta + _sig = sig + return + except Exception: + # Failsafe — telemetry must never raise from import-time work. + # Keep trying remaining candidates instead of giving up on the + # first malformed/unreadable file. + continue + + +def auth_headers() -> Dict[str, str]: + """Return the official-build HMAC headers, or ``{}`` if this is not an official build.""" + _load() + if _meta and _sig: + return { + 'X-Kestrel-Build-Meta': _meta, + 'X-Kestrel-Build-Sig': _sig, + } + return {} + + +def is_official() -> bool: + """True iff a usable attestation bundle was loaded.""" + _load() + return bool(_meta and _sig) diff --git a/analyzer/cli.py b/analyzer/cli.py index ae2d1ee4..8219ad01 100644 --- a/analyzer/cli.py +++ b/analyzer/cli.py @@ -17,17 +17,39 @@ ) +# Maps the GUI's "Wildlife Detection Model" select to the corresponding +# DETECTOR_ONNX_PATHS key. Matches the JS mapping in visualizer.js. +WILDLIFE_MODEL_MODE_TO_DETECTOR = { + "fast": "mdv1000-cedar", + "accurate": "mdv5a", +} + + def parse_args(argv: Sequence[str] | None = None): detector_choices = sorted(DETECTOR_ONNX_PATHS.keys()) parser = argparse.ArgumentParser(description="Kestrel Analyzer CLI") - parser.add_argument("folder", help="Folder with RAW/JPEG images") + parser.add_argument( + "folder", + nargs="?", + default=None, + help="Folder with RAW/JPEG images (optional when --validate is set)", + ) parser.add_argument("--gpu", dest="use_gpu", action="store_true", help="Use GPU (DirectML on Windows, CoreML on macOS) for ONNX") parser.add_argument("--no-gpu", dest="use_gpu", action="store_false", help="Force CPU for ONNX") parser.add_argument( "--detector-name", choices=detector_choices, - default=DEFAULT_DETECTOR_NAME, - help="Select detector model variant.", + default=None, + help="Select detector model variant by ONNX key (advanced). Overrides --wildlife-model-mode.", + ) + parser.add_argument( + "--wildlife-model-mode", + choices=("fast", "accurate"), + default=None, + help=( + "GUI-aligned detector selector: 'fast' uses mdv1000-cedar (smaller); " + "'accurate' uses mdv5a (larger, stronger recall). Default: 'accurate'." + ), ) parser.add_argument( "--detection-threshold", @@ -41,15 +63,126 @@ def parse_args(argv: Sequence[str] | None = None): default=3, help="Parallel RAW decode workers (1-5); matches desktop 'parallel prefetch' setting.", ) + parser.add_argument( + "--max-bird-crops", + type=int, + default=10, + help="Max bird/wildlife subjects saved per image (1-20); matches desktop default.", + ) + parser.add_argument( + "--exposure-quality", + choices=("lenient", "balanced", "aggressive"), + default=None, + help=( + "Exposure correction aggressiveness. 'lenient' (50%%, ±3 stops), " + "'balanced' (75%%, ±5 stops), 'aggressive' (100%%, ±8 stops). " + "Default: read settings.json or 'balanced'." + ), + ) + parser.add_argument( + "--scene-time-threshold", + type=float, + default=1.0, + help="Seconds between images to group as one burst/scene (0-60). Default: 1.0.", + ) + parser.add_argument( + "--thumbnail-max-width", + type=int, + default=None, + help=( + "Long-edge pixel width for cached analysis thumbnails (400-2400). " + "Default: read settings.json or 1200." + ), + ) + parser.add_argument( + "--thumbnail-jpeg-compression", + type=float, + default=None, + help=( + "Thumbnail JPEG quality factor (0.50-1.00). 0.75 means 75%% quality. " + "Default: read settings.json or 0.75." + ), + ) + parser.add_argument( + "--wildlife", + dest="wildlife_enabled", + action="store_true", + help="Enable non-bird wildlife detection (SpeciesNet, experimental).", + ) + parser.add_argument( + "--no-wildlife", + dest="wildlife_enabled", + action="store_false", + help="Disable non-bird wildlife detection (default).", + ) + parser.add_argument( + "--species-detection", + dest="species_detection_enabled", + action="store_true", + help="Run bird species classifier on each detection (default).", + ) + parser.add_argument( + "--no-species-detection", + dest="species_detection_enabled", + action="store_false", + help=( + "Skip bird species classification. Detection, quality scoring, and " + "culling are unaffected." + ), + ) + parser.add_argument( + "--retry-errored", + dest="retry_errored", + action="store_true", + help="Drop previously-errored rows and re-run analysis on them.", + ) + parser.add_argument( + "--no-retry-errored", + dest="retry_errored", + action="store_false", + help="Leave previously-errored rows alone (default).", + ) parser.add_argument( "--smoke", action="store_true", help="Load a single image via Wand and exit (skips model loading)", ) - parser.set_defaults(use_gpu=True) + parser.add_argument( + "--validate", + action="store_true", + help="Run the 9-check end-to-end validation harness and exit.", + ) + parser.add_argument( + "--validate-images", + type=str, + default=None, + help="Folder with at least 2 sample images for --validate (e.g. test_imgs/).", + ) + parser.add_argument( + "--validate-output", + type=str, + default=None, + help="Path for the --validate result JSON. Recommended on Windows where console=False hides stdout.", + ) + parser.set_defaults( + use_gpu=True, + wildlife_enabled=False, + species_detection_enabled=True, + retry_errored=False, + ) return parser.parse_args(argv) +def _resolve_detector_name(args) -> str: + """--detector-name takes priority over --wildlife-model-mode. If neither + is set, fall back to DEFAULT_DETECTOR_NAME (mdv5a / "accurate").""" + if args.detector_name: + return args.detector_name + if args.wildlife_model_mode: + return WILDLIFE_MODEL_MODE_TO_DETECTOR[args.wildlife_model_mode] + return DEFAULT_DETECTOR_NAME + + def _find_first_image(folder: str) -> str | None: files = [ f @@ -74,8 +207,26 @@ def main(argv: Sequence[str] | None = None): log_path = get_log_path(None) try: args = parse_args(argv) + if args.validate: + from kestrel_analyzer.validation import run_validation + sys.exit(run_validation( + images_dir=args.validate_images, + output_path=args.validate_output, + )) + if not args.folder: + print("error: 'folder' is required unless --validate is set", flush=True, file=sys.stderr) + sys.exit(2) detection_threshold = max(0.10, min(0.99, float(args.detection_threshold))) parallel_pf = max(1, min(5, int(float(args.parallel_prefetch)))) + max_bird_crops = max(1, min(20, int(float(args.max_bird_crops)))) + scene_time_threshold = max(0.0, min(60.0, float(args.scene_time_threshold))) + thumbnail_max_width = None + if args.thumbnail_max_width is not None: + thumbnail_max_width = max(400, min(2400, int(float(args.thumbnail_max_width)))) + thumbnail_jpeg_compression = None + if args.thumbnail_jpeg_compression is not None: + thumbnail_jpeg_compression = max(0.5, min(1.0, float(args.thumbnail_jpeg_compression))) + detector_name = _resolve_detector_name(args) log_path = get_log_path(args.folder) if args.smoke: log_event( @@ -90,13 +241,13 @@ def main(argv: Sequence[str] | None = None): if not image_path: print("No supported image files found.", flush=True) return - + print(f"Smoke test: reading image {os.path.basename(image_path)}", flush=True) print(f"Smoke test image path: {image_path}", flush=True) print(f"Smoke test image exists: {os.path.exists(image_path)}", flush=True) - + from kestrel_analyzer.image_utils import read_image - + img_array = read_image(image_path) if img_array is not None: print(f"Smoke test SUCCESS: Read image with shape {img_array.shape}", flush=True) @@ -105,7 +256,7 @@ def main(argv: Sequence[str] | None = None): return pipeline = AnalysisPipeline( use_gpu=args.use_gpu, - detector_name=args.detector_name, + detector_name=detector_name, ) def on_status(msg): @@ -121,9 +272,17 @@ def on_progress(processed, total): "event": "cli_start", "folder": args.folder, "use_gpu": args.use_gpu, - "detector_name": args.detector_name, + "detector_name": detector_name, "detection_threshold": detection_threshold, "parallel_prefetch": parallel_pf, + "max_bird_crops": max_bird_crops, + "scene_time_threshold": scene_time_threshold, + "exposure_quality": args.exposure_quality, + "thumbnail_max_width": thumbnail_max_width, + "thumbnail_jpeg_compression": thumbnail_jpeg_compression, + "wildlife_enabled": args.wildlife_enabled, + "species_detection_enabled": args.species_detection_enabled, + "retry_errored": args.retry_errored, }, ) @@ -134,8 +293,16 @@ def on_progress(processed, total): "on_progress": on_progress, }, analyzer_name="cli", + wildlife_enabled=args.wildlife_enabled, + species_detection_enabled=args.species_detection_enabled, detection_threshold=detection_threshold, + scene_time_threshold=scene_time_threshold, + max_bird_crops=max_bird_crops, parallel_prefetch=parallel_pf, + retry_errored=args.retry_errored, + exposure_quality=args.exposure_quality, + thumbnail_max_width=thumbnail_max_width, + thumbnail_jpeg_compression=thumbnail_jpeg_compression, ) print() except Exception as e: diff --git a/analyzer/css/base.css b/analyzer/css/base.css new file mode 100644 index 00000000..52947baa --- /dev/null +++ b/analyzer/css/base.css @@ -0,0 +1,72 @@ +:root { + --bg: #0b0c0f; + --panel: #111318; + --muted: #8a9099; + --text: #e7e9ee; + --brand: #4aa3ff; + --ok: #2ecc71; + --warn: #f39c12; + --bad: #e74c3c; + --card: #161922; + --card-border: #222634; + --chip: #1d2230; + /* New: dialog column sizing */ + --right-w: 510px; + --divider-w: 8px; +} + +* { + box-sizing: border-box; +} + +html, +body { + height: 100%; +} + +body { + margin: 0; + background: var(--bg); + color: var(--text); + font: 14px/1.35 system-ui, Segoe UI, Roboto, Helvetica, Arial, sans-serif; + display: flex; + flex-direction: column; + height: 100vh; + overflow: hidden; +} + +/* Generic utilities */ +.muted { + color: var(--muted); +} + +.hidden { + display: none !important; +} + +.notice { + background: #0e1117; + border: 1px dashed #3a84e6; + color: #a9c9ee; + padding: 8px 10px; + border-radius: 10px; + font-size: 13px; +} + +.footer { + display: flex; + gap: 8px; + justify-content: flex-end; +} + +/* Global kbd styling — used by welcome tips, tutorial body, workflow captions */ +kbd { + background: #161922; + border: 1px solid #2a3040; + border-radius: 4px; + padding: 1px 5px; + font-size: 11px; + font-family: monospace; + color: #cbd2dc; + white-space: nowrap; +} diff --git a/analyzer/css/dialogs/analyze.css b/analyzer/css/dialogs/analyze.css new file mode 100644 index 00000000..bd26cb27 --- /dev/null +++ b/analyzer/css/dialogs/analyze.css @@ -0,0 +1,522 @@ +/* ── Analyze Folders dialog ──────────────────────────────────── */ +.analyze-dlg { + display: flex; + flex-direction: column; + gap: 10px; + padding: 16px; + width: min(96vw, calc(100vw - 80px)); + height: min(88vh, calc(100vh - 80px)); + max-height: none; + box-sizing: border-box; +} + +.analyze-dlg h3 { + margin: 0; + font-size: 16px; +} + +/* Split layout: folder tree (left) + queue preview (right) */ +.analyze-dlg-split { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} +/* Stack on narrow screens */ +@media (max-width: 640px) { + .analyze-dlg-split { grid-template-columns: 1fr; } + .analyze-dlg { width: min(520px, 96vw); } +} + +.analyze-dlg-tree-wrap { + overflow: auto; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + padding: 4px 2px; + min-height: 0; +} + +/* Right panel: queue preview */ +.analyze-dlg-queue-wrap { + display: flex; + flex-direction: column; + gap: 8px; + overflow: auto; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + padding: 8px; + min-height: 0; +} + +.analyze-dlg-queue-wrap h4 { + margin: 0; + font-size: 13px; + color: var(--muted); + font-weight: 600; +} + +.adlg-queue-section { margin-bottom: 6px; } +.adlg-queue-section-title { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: 0.5px; + margin-bottom: 4px; + font-weight: 600; +} + +.adlg-queue-item { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 6px; + border-radius: 4px; + font-size: 12px; +} +.adlg-queue-item:hover { background: rgba(255,255,255,.04); } + +.adlg-queue-item .adlg-qi-name { + flex: 1; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.adlg-queue-item .adlg-qi-status { + font-size: 10px; + color: var(--muted); + flex-shrink: 0; +} + +.adlg-queue-item .adlg-qi-remove { + flex-shrink: 0; + background: none; + border: none; + color: #e74c3c; + cursor: pointer; + font-size: 11px; + padding: 1px 4px; + border-radius: 3px; + opacity: 0.6; +} +.adlg-queue-item .adlg-qi-remove:hover { opacity: 1; background: rgba(231,76,60,.12); } + +/* Drag-and-drop for pending queue items */ +.adlg-queue-item[draggable="true"] { cursor: grab; } +.adlg-queue-item[draggable="true"]:active { cursor: grabbing; } +.adlg-queue-item.drag-over { border-top: 2px solid var(--brand); margin-top: -2px; } +.adlg-queue-item.dragging { opacity: 0.35; } +.adlg-qi-grip { + flex-shrink: 0; + color: #555; + font-size: 11px; + cursor: grab; + user-select: none; + padding: 0 2px; +} + +.adlg-queue-empty { + color: var(--muted); + font-size: 12px; + font-style: italic; + padding: 12px 6px; + text-align: center; +} + +.analyze-dlg-options { + display: flex; + align-items: center; + gap: 12px; + flex-wrap: wrap; + font-size: 13px; +} + +/* Advanced Analysis Settings (collapsible) */ +.analyze-dlg-advanced { + border: 1px solid #1d2230; + border-radius: 8px; + background: #0d1017; + font-size: 13px; +} + +.analyze-dlg-advanced summary { + cursor: pointer; + padding: 7px 12px; + color: #a0a8b8; + font-weight: 600; + font-size: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; + user-select: none; +} +.analyze-dlg-advanced summary:hover { color: #c8cdd8; } + +.analyze-dlg-advanced-body { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(320px, 1fr)); + gap: 12px; + padding: 4px 12px 12px; + align-items: start; +} + +@media (max-width: 780px) { + .analyze-dlg-advanced-body { + grid-template-columns: 1fr; + } +} + +.adlg-group { + display: flex; + flex-direction: column; + gap: 4px; + padding: 10px 12px; + border: 1px solid #161a24; + border-radius: 6px; + background: #0a0d13; +} + +.adlg-group-title { + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.6px; + color: #7a8598; + margin-bottom: 4px; +} + +.adlg-toggle-row { + display: flex; + align-items: flex-start; + gap: 10px; + padding: 6px 0; + cursor: pointer; +} +.adlg-toggle-row input[type="checkbox"] { + margin-top: 2px; + flex-shrink: 0; +} +.adlg-toggle-text { + display: flex; + flex-direction: column; + gap: 2px; + flex: 1; +} +.adlg-toggle-label { + font-size: 13px; + color: #d4d8e0; +} +.adlg-toggle-hint { + font-size: 11px; + line-height: 1.35; +} +.adlg-tag { + display: inline-block; + margin-left: 6px; + padding: 1px 6px; + font-size: 10px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.4px; + color: #d0b8ff; + background: #3a2a5a; + border: 1px solid #5a4a7a; + border-radius: 4px; + vertical-align: middle; +} + +.adlg-setting-row { + display: flex; + align-items: center; + gap: 10px; + margin-top: 4px; +} +.adlg-setting-row label { + flex: 1; + font-size: 13px; +} +.adlg-setting-row input[type="number"], +.adlg-setting-row select { + flex-shrink: 0; +} +.adlg-hint { + font-size: 11px; + margin-top: -2px; + line-height: 1.35; +} + +.analyze-dlg-footer { + display: flex; + gap: 8px; + align-items: center; +} + +.analyze-dlg-selected-count { + flex: 1; + font-size: 12px; + color: var(--muted); +} + +/* Queue checkboxes in the analyze dialog (amber accent) */ +.adlg-cb { + flex: 0 0 13px; + width: 13px; + height: 13px; + margin: 0; + cursor: pointer; + accent-color: #f39c12; +} + +.adlg-node-row.queue-sel { + background: rgba(243, 156, 18, .1) !important; +} + +.adlg-node-row.queue-sel .tree-label { + color: #f5c542 !important; +} + +/* ── Analysis Queue floating notification panel ────────────────── */ +.queue-panel { + position: fixed; + bottom: 20px; + right: 20px; + width: 380px; + background: #111318; + border: 1px solid #2a3040; + border-radius: 12px; + box-shadow: 0 8px 36px rgba(0, 0, 0, .7); + z-index: 400; + display: flex; + flex-direction: column; + overflow: hidden; +} + +.queue-panel.hidden { + display: none !important; +} + +.queue-panel-header { + display: flex; + align-items: center; + gap: 8px; + padding: 10px 14px; + background: #161922; + border-bottom: 1px solid #1d2230; + cursor: pointer; + user-select: none; +} + +.queue-panel-title { + flex: 1; + font-size: 13px; + font-weight: 600; +} + +.queue-panel-badge { + font-size: 11px; + padding: 1px 7px; + border-radius: 999px; + background: #1d5aa8; + border: 1px solid #2b6ec8; + color: #c8dcf5; +} + +.queue-panel-badge.paused { + background: #3a2408; + border-color: #7a5010; + color: #f5c542; +} + +.queue-panel-badge.done { + background: #0a2218; + border-color: #1a5e36; + color: #2ecc71; +} + +.queue-panel-toggle { + font-size: 10px; + color: var(--muted); + transition: transform .15s; +} + +.queue-panel-toggle.open { + transform: rotate(180deg); +} + +.queue-panel-body { + padding: 8px; + max-height: 340px; + overflow-y: auto; + display: flex; + flex-direction: column; + gap: 6px; +} + +.queue-panel-controls { + display: flex; + gap: 6px; + padding: 6px 8px; + border-top: 1px solid #1d2230; + flex-wrap: wrap; +} + +.queue-panel-controls button { + flex: 1; + font-size: 12px; + height: 28px; + min-width: 60px; +} + +.queue-item { + display: flex; + flex-direction: column; + gap: 3px; + padding: 6px 8px; + background: #0e1117; + border: 1px solid #1d2230; + border-radius: 8px; +} + +.queue-item.done-item { + opacity: .7; +} + +.queue-item-header { + display: flex; + align-items: center; + gap: 6px; +} + +.queue-item-name { + flex: 1; + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-item-status { + font-size: 11px; + padding: 1px 6px; + border-radius: 999px; + flex-shrink: 0; + white-space: nowrap; +} + +.queue-item-status.pending { + background: #0f2310; + color: #3abf67; + border: 1px solid #1b6638; +} + +.queue-item-status.running { + background: #0f2b4d; + color: #4aa3ff; + border: 1px solid #1d4f8c; +} + +.queue-item-status.done { + background: #0a2218; + color: #2ecc71; + border: 1px solid #1a5e36; +} + +.queue-item-status.error { + background: #2a0a0a; + color: #e74c3c; + border: 1px solid #5e1a1a; +} + +.queue-item-status.cancelled { + background: #1d2230; + color: #8a9099; +} + +.queue-item-progress { + height: 4px; + background: #1d2230; + border-radius: 999px; + overflow: hidden; + margin-top: 3px; +} + +.queue-item-progress-fill { + height: 100%; + background: #3a84e6; + border-radius: 999px; + transition: width .4s ease; +} + +.queue-item-eta { + font-size: 11px; + color: var(--muted); + margin-top: 2px; +} + +.queue-item-file { + font-size: 11px; + color: #6a8aaa; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + margin-top: 1px; + max-width: 100%; +} + +/* Live preview inside queue panel */ +.queue-live-preview { + display: flex; + gap: 8px; + margin-top: 6px; +} + +.queue-live-thumb { + width: 100px; + min-width: 100px; + aspect-ratio: 3/2; + background: #0a0d12; + border-radius: 6px; + overflow: hidden; + border: 1px solid #1d2230; + display: flex; + align-items: center; + justify-content: center; +} + +.queue-live-thumb img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.queue-live-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 4px; + min-width: 0; + justify-content: center; +} + +.queue-live-info .ql-species { + font-size: 12px; + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.queue-live-info .ql-detail { + font-size: 11px; + color: var(--muted); +} + +.queue-overall-eta { + font-size: 11px; + color: var(--muted); + padding: 3px 8px 5px; + text-align: right; +} diff --git a/analyzer/css/dialogs/base.css b/analyzer/css/dialogs/base.css new file mode 100644 index 00000000..96b26fe5 --- /dev/null +++ b/analyzer/css/dialogs/base.css @@ -0,0 +1,103 @@ +/* Z-index stacking: dialog backdrop sits below floating toasts */ +dialog::backdrop { + z-index: 10000; +} + +/* Place dialogs below the toasts */ +dialog { + z-index: 11000; +} + +/* Toasts should always be on top */ +#toastContainer { + z-index: 9999999; + pointer-events: none; + position: fixed !important; +} + +/* Modal. + Default: size to content in both dimensions. Dialogs that genuinely + need the full viewport (scene viewer, settings) opt in explicitly + below. This keeps small dialogs (merge, info, adjust-time, etc.) + from stretching to window width/height when they only have a few + rows of content. + + Note: we deliberately use `fit-content` rather than `auto` for both + dimensions. In Chromium, a centered with `height:auto` + + `max-height:Xvh` + `overflow-y:auto` can end up rendering at the + max-height instead of shrinking to content — `fit-content` + consistently hugs the content. */ +dialog { + border: 1px solid #2a3040; + background: var(--panel); + color: var(--text); + border-radius: 14px; + width: fit-content; + max-width: min(1200px, 96vw); + height: fit-content; + max-height: 92vh; + overflow-x: hidden; + overflow-y: auto; +} + +dialog::backdrop { + background: rgba(0, 0, 0, .6); + backdrop-filter: blur(2px); +} + +/* Compact dialogs that should size to their content */ +dialog.dlg-compact { + width: fit-content; + height: fit-content; + max-height: 92vh; + overflow-x: hidden; + overflow-y: auto; +} + +dialog#analyzeQueueDlg, +dialog#liveAnalysisDlg { + width: 98vw; + max-width: 98vw; +} + +dialog#sceneDlg { + width: calc(100% - 60px); + height: calc(100% - 60px); + max-width: none; + max-height: none; +} + +/* Legacy modal fallback (keep for dialogs that still use .modal) */ +.modal { + display: grid; + grid-template-columns: minmax(0, 1fr) var(--divider-w) var(--right-w); + gap: 0; + height: 100%; +} +.modal .left { + padding: 0; + min-height: 60vh; + overflow: auto; +} +.modal .right { + border-left: 1px solid #2a3040; + padding: 12px; + display: grid; + gap: 10px; + grid-template-rows: auto auto auto auto 1fr auto auto; + overflow-y: auto; + overflow-x: hidden; +} +.modal .grid { + grid-template-columns: repeat(auto-fill, minmax(180px, 1fr)); + padding: 12px; +} + +.detail { + font-size: 13px; + color: #cbd2dc; + background: #10131a; + border: 1px solid #1f2533; + border-radius: 10px; + padding: 10px; +} diff --git a/analyzer/css/dialogs/donate.css b/analyzer/css/dialogs/donate.css new file mode 100644 index 00000000..e06cda49 --- /dev/null +++ b/analyzer/css/dialogs/donate.css @@ -0,0 +1,71 @@ +/* ── Donate button ────────────────────────────────────────────── */ +.donate-btn { + padding: 5px 10px; + height: auto; + border-radius: 6px; + background: rgba(111, 78, 39, 0.22); + border: 1px solid #7a5530; + color: #e8c87a; + font-size: 12px; + font-weight: 600; + cursor: pointer; + white-space: nowrap; + flex-shrink: 0; + transition: background .15s, border-color .15s; +} +.donate-btn:hover { + background: rgba(111, 78, 39, 0.45); + border-color: #c49050; + color: #f5d98b; +} +/* ── Donation prompt dialog ──────────────────────────────────── */ +#donateDlg { + width: fit-content !important; + height: fit-content !important; + max-width: min(520px, 92vw); + max-height: 92vh; + min-width: 0; + overflow-x: hidden; + overflow-y: auto; + padding: 0; +} +.donate-dlg { + display: flex; + flex-direction: column; + align-items: center; + gap: 14px; + padding: 28px 28px 20px; + width: min(480px, 90vw); + box-sizing: border-box; + text-align: center; +} +.donate-dlg-icon { width: 52px; height: 52px; display: flex; align-items: center; justify-content: center; margin-bottom: 2px; } +.donate-dlg-icon img { width: 100%; height: 100%; object-fit: contain; } +.donate-dlg h3 { margin: 0; font-size: 17px; line-height: 1.4; } +.donate-dlg p { margin: 0; font-size: 13px; color: var(--muted); line-height: 1.65; } +.donate-dlg a { color: #4aa3ff; text-decoration: none; } +.donate-dlg a:hover { text-decoration: underline; } +.donate-dlg-btns { display: flex; gap: 10px; justify-content: center; flex-wrap: wrap; width: 100%; } +.donate-main-btn { + background: linear-gradient(135deg, #7a5428 0%, #955f2a 100%); + color: #f5d98b; + border: 1px solid #b07838; + border-radius: 8px; + padding: 12px 28px; + font-size: 14px; + font-weight: 700; + cursor: pointer; + transition: filter .15s; + height: auto; +} +.donate-main-btn:hover { filter: brightness(1.15); } +.donate-dlg-close-btn { + font-size: 12px; + background: transparent; + border: none; + color: var(--muted); + cursor: pointer; + padding: 4px 8px; + transition: color .15s; +} +.donate-dlg-close-btn:hover { color: var(--text); background: transparent; border: none; } diff --git a/analyzer/css/dialogs/feedback-consent.css b/analyzer/css/dialogs/feedback-consent.css new file mode 100644 index 00000000..979649f8 --- /dev/null +++ b/analyzer/css/dialogs/feedback-consent.css @@ -0,0 +1,124 @@ +/* ── Feedback Dialog ───────────────────────────────────────────── */ +.feedback-dlg { + display: flex; + flex-direction: column; + gap: 12px; + padding: 20px; + width: 100%; + box-sizing: border-box; +} + +.feedback-dlg h3 { + margin: 0; + font-size: 16px; +} + +.feedback-dlg .f-label { + font-size: 12px; + color: var(--muted); + margin-bottom: 3px; + display: block; +} + +.feedback-dlg textarea { + width: 100%; + min-height: 100px; + resize: vertical; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + color: var(--text); + padding: 8px 10px; + font-size: 13px; + font-family: inherit; + box-sizing: border-box; +} + +.feedback-dlg textarea:focus { + outline: none; + border-color: var(--brand); +} + +.feedback-dlg input[type="email"] { + width: 100%; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + color: var(--text); + padding: 7px 10px; + font-size: 13px; + box-sizing: border-box; +} + +.feedback-dlg input[type="email"]:focus { + outline: none; + border-color: var(--brand); +} + +.feedback-dlg select { + width: 100%; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + color: var(--text); + padding: 7px 10px; + font-size: 13px; + box-sizing: border-box; +} + +.feedback-dlg select:focus { + outline: none; + border-color: var(--brand); +} + +.feedback-ss-preview { + max-width: 100%; + max-height: 120px; + object-fit: contain; + border-radius: 6px; + border: 1px solid #1d2230; + background: #0a0d12; + margin-top: 4px; +} + +/* ── Analytics Consent Dialog ─────────────────────────────────── */ +.analytics-dlg { + display: flex; + flex-direction: column; + gap: 14px; + padding: 24px; + width: 100%; + box-sizing: border-box; +} + +.analytics-dlg h3 { + margin: 0; + font-size: 17px; +} + +.analytics-dlg p { + margin: 0; + font-size: 13px; + color: var(--muted); + line-height: 1.6; +} + +.analytics-dlg ul { + margin: 4px 0 0; + padding-left: 18px; + font-size: 13px; + color: var(--muted); + line-height: 1.75; +} + +.analytics-dlg .analytics-note { + font-size: 12px; + color: #607080; +} + +.analytics-dlg .analytics-btns { + display: flex; + gap: 8px; + justify-content: flex-end; + margin-top: 4px; +} diff --git a/analyzer/css/dialogs/live-analysis.css b/analyzer/css/dialogs/live-analysis.css new file mode 100644 index 00000000..5b9969db --- /dev/null +++ b/analyzer/css/dialogs/live-analysis.css @@ -0,0 +1,185 @@ +/* ── Live Analysis Details dialog ───────────────────────────────────── */ +.live-dlg { + display: grid; + grid-template-columns: 2.2fr 0.8fr; + grid-template-rows: auto auto 1fr; + gap: 12px; + padding: 16px; + width: min(96vw, calc(100vw - 80px)); + height: min(88vh, calc(100vh - 80px)); + box-sizing: border-box; + overflow: hidden; +} + +.live-dlg-hdr { + grid-column: 1 / -1; + grid-row: 1; + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + flex: 0 0 auto; +} + +.live-dlg-title { + font-size: 15px; + font-weight: 600; + flex: 1; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.live-dlg-fname { + font-size: 12px; + color: #6a8aaa; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + max-width: 300px; +} + +.live-dlg-status-bar { + grid-column: 1 / -1; + grid-row: 2; + font-size: 12px; + color: var(--muted); + min-height: 16px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 0 auto; +} + +.live-dlg-preview-row { + grid-column: 1; + grid-row: 3; + display: grid; + grid-template-columns: 1fr 1fr; + gap: 10px; + flex: 1 1 0; + min-height: 0; + overflow: hidden; +} + +.live-dlg-img-cell { + background: #05080f; + border: 1px solid #1d2230; + border-radius: 8px; + padding: 8px; + display: flex; + flex-direction: column; + gap: 6px; + min-height: 0; + overflow: hidden; +} + +.live-dlg-img-label { + font-size: 11px; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .05em; + flex: 0 0 auto; +} + +.live-dlg-img { + flex: 1 1 0; + min-height: 0; + width: 100%; + object-fit: contain; + background: #020508; + border-radius: 4px; + display: block; +} + +.live-dlg-detections-section { + grid-column: 2; + grid-row: 3; + display: flex; + flex-direction: column; + gap: 8px; + min-height: 0; + overflow: hidden; +} + +.live-dlg-section-label { + font-size: 11px; + font-weight: 700; + color: var(--muted); + text-transform: uppercase; + letter-spacing: .06em; + margin-top: 0; + flex: 0 0 auto; +} + +.live-dlg-crops-row { + display: grid; + grid-template-columns: repeat(2, 1fr); + align-content: start; + gap: 8px; + flex: 1 1 0; + min-height: 0; + overflow-y: auto; + overflow-x: hidden; +} + +.live-dlg-crop-card { + background: #080d12; + border: 1px solid #1d2230; + border-radius: 8px; + padding: 7px; + display: flex; + flex-direction: column; + gap: 3px; + transition: opacity .2s; +} + +.live-dlg-crop-img { + width: 100%; + aspect-ratio: 1; + object-fit: cover; + background: #020508; + border-radius: 5px; + display: block; +} + +.ldc-conf { + font-size: 11px; + color: var(--muted); +} + +.ldc-quality { + font-size: 11px; + color: #9fb7cc; + font-variant-numeric: tabular-nums; +} + +.ldc-stars { + font-size: 13px; + letter-spacing: .05em; +} + +.ldc-species { + font-size: 12px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ldc-family { + font-size: 11px; + color: var(--muted); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.high-conf { + font-weight: 700; + color: #d0eaff !important; +} + +.low-conf { + font-style: italic; + color: #606878 !important; +} diff --git a/analyzer/css/dialogs/scene.css b/analyzer/css/dialogs/scene.css new file mode 100644 index 00000000..a523367d --- /dev/null +++ b/analyzer/css/dialogs/scene.css @@ -0,0 +1,751 @@ +/* ═══ Filmstrip Scene View Layout ═══ */ +.scene-filmstrip-layout { + display: flex; + flex-direction: column; + height: 100%; + overflow: hidden; +} + +/* ── TOP BAR ── */ +.scene-topbar { + display: flex; + align-items: center; + gap: 10px; + padding: 8px 14px; + background: #111318; + border-bottom: 1px solid #1f2533; + flex-shrink: 0; + min-height: 44px; +} +.scene-topbar-left { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; + min-width: 0; +} +.scene-topbar-title { + font-weight: 700; + font-size: 14px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + max-width: 300px; +} +.scene-pencil-btn { + background: none; + border: 1px solid transparent; + padding: 2px 6px; + font-size: 13px; + cursor: pointer; + border-radius: 6px; + height: auto; + color: var(--muted); + transition: color .15s, border-color .15s; +} +.scene-pencil-btn:hover { + color: var(--text); + border-color: #2a3040; + background: #1d2230; +} +.scene-rename-inline { + display: flex; + align-items: center; + gap: 4px; +} +.scene-rename-inline input { + height: 28px; + font-size: 13px; + width: 200px; + padding: 0 8px; +} +.scene-rename-inline button { + height: 28px; + padding: 0 8px; + font-size: 13px; +} +.scene-topbar-tags { + display: flex; + align-items: center; + gap: 6px; + flex: 1 1 auto; + min-width: 0; + overflow-x: auto; + flex-wrap: nowrap; + scrollbar-width: none; +} +.scene-topbar-tags::-webkit-scrollbar { display: none; } +.scene-topbar-tags .chip { + flex-shrink: 0; +} +.scene-topbar-tags .scene-tag-label { + color: var(--muted); + font-size: 11px; + font-weight: 600; + flex-shrink: 0; + white-space: nowrap; +} +.scene-topbar-tags .scene-tag-sep { + width: 1px; + height: 16px; + background: #2a3040; + flex-shrink: 0; +} +.scene-chip-add { + cursor: pointer; + background: transparent; + border: 1px dashed #3a84e6; + color: #6aa0ff; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + flex-shrink: 0; + height: auto; + line-height: 1.3; + white-space: nowrap; +} +.scene-chip-add:hover { background: #1a2a44; } +.scene-chip-suggested { + cursor: pointer; + background: rgba(58, 132, 230, 0.1); + border: 1px dashed #3a84e6; + color: #77acff; + border-radius: 999px; + padding: 2px 8px; + font-size: 11px; + flex-shrink: 0; + height: auto; + line-height: 1.3; + white-space: nowrap; + transition: background .15s, color .15s, border-color .15s; +} +.scene-chip-suggested em { + font-style: italic; + color: #9bc5ff; +} +.scene-chip-suggested:hover { + background: rgba(58, 132, 230, 0.2); + border-color: #5f9af2; + color: #c8deff; +} +.scene-topbar-tags .chip-x { + margin-left: 5px; + color: var(--muted); + cursor: pointer; + transition: color .15s; +} +.scene-topbar-tags .chip-x:hover { color: var(--danger, #ff4d4f); } +.scene-topbar-right { + display: flex; + align-items: center; + gap: 6px; + flex-shrink: 0; +} +.scene-shortcut-btn { + background: none; + border: 1px solid transparent; + padding: 2px 8px; + font-size: 16px; + cursor: pointer; + border-radius: 6px; + height: auto; + color: var(--muted); +} +.scene-shortcut-btn:hover { + color: var(--text); + border-color: #2a3040; + background: #1d2230; +} + +/* ── Shortcut legend bar ── */ +.scene-shortcut-legend { + display: flex; + align-items: center; + gap: 14px; + padding: 5px 14px; + background: rgba(74,163,255,0.06); + border-bottom: 1px solid #1f2533; + font-size: 11px; + color: var(--muted); + flex-shrink: 0; + flex-wrap: wrap; +} +.scene-shortcut-legend kbd { + display: inline-block; + background: #1d2230; + border: 1px solid #2a3040; + border-radius: 4px; + padding: 1px 5px; + font-size: 10px; + font-family: inherit; + color: #a9c9ee; + margin: 0 2px; +} + + + +/* ── CENTER: Dual previews ── */ +.scene-main { + flex: 1 1 auto; + display: flex; + flex-direction: column; + min-height: 0; + overflow: hidden; +} +.scene-previews { + flex: 1 1 auto; + display: flex; + gap: 0; + padding: 8px 14px 4px; + min-height: 0; +} +#scenePreviewCrop { + flex: 0.32 1 0; + min-width: 140px; +} +#scenePreviewExport { + flex: 0.68 1 0; + min-width: 180px; +} +.scene-preview-divider { + flex: 0 0 10px; + width: 10px; + position: relative; + cursor: col-resize; + touch-action: none; + user-select: none; + outline: none; +} +.scene-preview-divider::before { + content: ''; + position: absolute; + top: 8px; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + width: 2px; + border-radius: 2px; + background: #2a3040; + transition: background .12s ease; +} +.scene-preview-divider:hover::before, +.scene-preview-divider.dragging::before, +.scene-preview-divider:focus-visible::before { + background: #3a84e6; +} +.scene-preview-panel { + display: flex; + flex-direction: column; + min-height: 0; + border: 1px solid #1f2533; + border-radius: 10px; + background: #0e1117; + overflow: hidden; + position: relative; +} +.scene-preview-panel.scene-accepted { + border-color: rgba(46,204,113,0.5); + box-shadow: 0 0 12px rgba(46,204,113,0.15); +} +.scene-preview-panel.scene-rejected { + border-color: rgba(231,76,60,0.5); + box-shadow: 0 0 12px rgba(231,76,60,0.15); +} +.scene-preview-label { + font-size: 11px; + color: var(--muted); + padding: 4px 10px 2px; + text-transform: uppercase; + letter-spacing: .5px; + flex-shrink: 0; +} +.scene-preview-img { + flex: 1 1 auto; + display: flex; + align-items: center; + justify-content: center; + min-height: 0; + overflow: hidden; + position: relative; +} +.scene-preview-img img { + max-width: 100%; + max-height: 100%; + object-fit: contain; + image-rendering: auto; + display: block; +} +.scene-preview-copy-btn { + position: absolute; + right: 10px; + bottom: 10px; + z-index: 6; + display: inline-flex; + align-items: center; + gap: 5px; + padding: 4px 8px; + border: 1px solid #2a3040; + border-radius: 7px; + background: rgba(14,17,23,0.88); + color: #cbd2dc; + font-size: 11px; + font-weight: 600; + line-height: 1.1; + cursor: pointer; + transition: background .12s ease, border-color .12s ease, color .12s ease, opacity .12s ease; +} +.scene-preview-copy-btn:hover { + background: #1b2a42; + border-color: #3a84e6; + color: #fff; +} +.scene-preview-copy-btn:disabled { + cursor: not-allowed; + opacity: 0.45; +} +.scene-preview-copy-btn .copy-icon { + font-size: 12px; + line-height: 1; +} +.scene-zoom-bar { + display: flex; + align-items: center; + gap: 6px; + padding: 3px 10px; + flex-shrink: 0; + background: rgba(16,19,26,0.7); +} + +/* Scene dialog RAW zoom mode (export preview only) */ +.scene-preview-img.zoom-active { + overflow: hidden; + cursor: crosshair; + border: 1px solid var(--brand); +} +.scene-preview-img.zoom-active img { + object-fit: cover; + transition: none; + image-rendering: crisp-edges; + backface-visibility: hidden; + transform-style: preserve-3d; +} +.scene-preview-img.zoom-active canvas.scene-zoom-canvas { + position: absolute; + inset: 0; + width: 100%; + height: 100%; + display: block; + pointer-events: none; +} +.scene-preview-img.raw-loaded::after { + content: attr(data-raw-label); + position: absolute; + top: 6px; + right: 6px; + background: var(--brand); + color: #fff; + font-size: 9px; + font-weight: 700; + padding: 1px 5px; + border-radius: 3px; + pointer-events: none; +} + +.scene-crop-overlay-layer { + position: absolute; + inset: 0; + pointer-events: none; + opacity: 0; + transition: opacity .12s ease; + z-index: 5; +} +.scene-crop-overlay-layer.visible { + opacity: 1; +} +.scene-crop-overlay-box { + position: absolute; + border: 2px solid #94a4ba; + background: rgba(148, 164, 186, 0.07); + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.35); +} +.scene-crop-overlay-box.active { + border-color: #f2c14c; + background: rgba(242, 193, 76, 0.08); + box-shadow: 0 0 0 1px rgba(242, 193, 76, 0.35), inset 0 0 0 1px rgba(0, 0, 0, 0.45); +} +.scene-crop-overlay-box.inactive { + border-style: dashed; +} +.scene-crop-overlay-label { + position: absolute; + top: -16px; + left: 0; + font-size: 10px; + font-weight: 700; + line-height: 1; + padding: 2px 5px; + border-radius: 4px; + color: #0d1117; + background: rgba(148, 164, 186, 0.95); +} +.scene-crop-overlay-box.active .scene-crop-overlay-label { + background: rgba(242, 193, 76, 0.98); +} + +/* ── Info bar ── */ +.scene-info-bar { + display: flex; + align-items: center; + gap: 14px; + padding: 6px 14px; + background: #111318; + border-top: 1px solid #1f2533; + font-size: 13px; + flex-shrink: 0; + flex-wrap: wrap; +} +.scene-info-filename { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 0 1 auto; + cursor: default; +} +.scene-info-filename[title]:hover { + color: var(--brand); +} +.scene-info-quality { + color: #a9c9ee; + font-size: 12px; + flex-shrink: 0; +} +.scene-cull-toggle { + display: flex; + background: #1a1e26; + border-radius: 6px; + padding: 2px; + gap: 2px; + border: 1px solid #2a3040; +} +.cull-btn { + background: transparent; + border: none; + color: #6a768c; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + transition: all 0.15s ease; + white-space: nowrap; +} +.cull-btn:hover { + color: #cbd2dc; + background: #252d40; +} +.cull-btn.active.reject { + background: #e74c3c; + color: #fff; +} +.cull-btn.active.unrated { + background: #34495e; + color: #fff; +} +.cull-btn.active.accept { + background: #2ecc71; + color: #fff; +} +.cull-btn:not(.active) { + opacity: 0.6; +} +.scene-info-meta { + color: var(--muted); + font-size: 12px; + margin-left: auto; + flex-shrink: 0; + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} +.scene-info-meta-text { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; +} +.scene-info-crop-nav { + display: inline-flex; + align-items: center; + gap: 2px; + background: #1a1e26; + border: 1px solid #2a3040; + border-radius: 6px; + padding: 2px; + flex-shrink: 0; +} +.scene-info-crop-nav.hidden { display: none; } +.scene-info-crop-btn { + background: transparent; + border: none; + color: #9fb0cc; + font-size: 13px; + line-height: 1; + padding: 3px 7px; + border-radius: 4px; + cursor: pointer; + transition: background .12s ease, color .12s ease; +} +.scene-info-crop-btn:hover { + background: #252d40; + color: var(--brand); +} +.scene-info-crop-btn:disabled { + opacity: 0.35; + cursor: not-allowed; +} +.scene-info-crop-label { + font-size: 11px; + color: #cbd2dc; + font-weight: 600; + padding: 0 4px; + min-width: 54px; + text-align: center; + white-space: nowrap; +} +.scene-info-editor-btn { + display: inline-flex; + align-items: center; + gap: 5px; + background: #1a2235; + border: 1px solid #2a3d5c; + color: #a9c9ee; + font-size: 11px; + font-weight: 600; + padding: 4px 10px; + border-radius: 6px; + cursor: pointer; + transition: background .12s ease, border-color .12s ease, color .12s ease, opacity .12s ease; + flex-shrink: 0; +} +.scene-info-editor-btn:hover { + background: #1b2a42; + border-color: var(--brand); + color: #fff; +} +.scene-info-editor-btn:disabled { + opacity: 0.45; + cursor: not-allowed; +} +.scene-info-editor-icon { + font-size: 13px; + line-height: 1; +} + +/* ── BOTTOM: Filmstrip ── */ +.scene-filmstrip-wrap { + display: flex; + align-items: stretch; + flex-shrink: 0; + height: 140px; + border-top: 1px solid #1f2533; + background: #0b0d12; + position: relative; +} +.scene-filmstrip { + flex: 1 1 auto; + display: flex; + gap: 6px; + padding: 8px 10px; + overflow-x: auto; + overflow-y: hidden; + scroll-behavior: smooth; + align-items: stretch; +} +.scene-filmstrip::-webkit-scrollbar { + height: 4px; +} +.scene-filmstrip::-webkit-scrollbar-track { + background: transparent; +} +.scene-filmstrip::-webkit-scrollbar-thumb { + background: #2a3040; + border-radius: 2px; +} +.scene-filmstrip-hint { + display: flex; + align-items: center; + justify-content: center; + padding: 0 8px; + font-size: 11px; + color: var(--muted); + background: rgba(17,19,24,0.85); + white-space: nowrap; + cursor: pointer; + user-select: none; + flex-shrink: 0; + border-left: 1px solid #1f2533; + border-right: 1px solid #1f2533; + transition: color .15s, background .15s; +} +.scene-filmstrip-hint:hover { + color: var(--brand); + background: rgba(26,42,68,0.5); +} +.scene-filmstrip-hint-left { + border-right: 1px solid #1f2533; + border-left: none; +} +.scene-filmstrip-hint-right { + border-left: 1px solid #1f2533; + border-right: none; +} + +/* Filmstrip card */ +.filmstrip-card { + flex-shrink: 0; + width: 104px; + display: flex; + flex-direction: column; + background: var(--card); + border: 2px solid #2a3040; + border-radius: 8px; + overflow: hidden; + cursor: pointer; + transition: border-color .12s ease, box-shadow .12s ease; + position: relative; +} +.filmstrip-card:hover { + border-color: #3a4a6a; +} +.filmstrip-card.active { + border-color: var(--brand); + border-width: 3px; + box-shadow: 0 0 10px rgba(74,163,255,0.3); +} + +.filmstrip-card.accepted.manual-cull .filmstrip-thumb, +.filmstrip-card.accepted.verified-cull .filmstrip-thumb { + border: 2px solid #2ecc71; +} +.filmstrip-card.rejected.manual-cull .filmstrip-thumb, +.filmstrip-card.rejected.verified-cull .filmstrip-thumb { + border: 2px solid #e74c3c; +} + +.filmstrip-card .filmstrip-thumb { + width: 100%; + aspect-ratio: 4/3; + background: #0e1117; + overflow: hidden; + flex-shrink: 0; + box-sizing: border-box; + border: 2px solid transparent; + border-radius: 6px; +} +.filmstrip-card .filmstrip-thumb img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} +.filmstrip-card .filmstrip-info { + padding: 3px 6px 4px; + display: flex; + flex-direction: column; + gap: 2px; + flex: 1 0 auto; +} +.filmstrip-card .filmstrip-filename { + font-size: 10px; + color: var(--text); + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} +.filmstrip-card .filmstrip-meta { + display: flex; + justify-content: space-between; + align-items: center; + font-size: 10px; + color: var(--muted); +} +.filmstrip-card .filmstrip-stars { + font-size: 9px; + letter-spacing: 1px; +} +.filmstrip-card .filmstrip-stars .filled.auto { color: #6aa0ff; } +.filmstrip-card .filmstrip-stars .filled.manual { color: #f5c542; } +/* Split mode on filmstrip */ +.filmstrip-card.split-mode { position: relative; } +.filmstrip-card.split-mode .split-check { + position: absolute; + top: 4px; + left: 4px; + z-index: 3; + width: 16px; + height: 16px; + accent-color: #3a84e6; +} +.filmstrip-card.split-selected { + outline: 2px solid #3a84e6; + outline-offset: -2px; +} + +@keyframes split-scene-btn-flash { + 0% { + box-shadow: 0 0 0 0 rgba(245, 197, 66, 0); + transform: translateY(0); + } + 35% { + box-shadow: 0 0 0 3px rgba(245, 197, 66, 0.45); + border-color: #f5c542; + background: #2f3d5a; + color: #fff0bf; + } + 100% { + box-shadow: 0 0 0 0 rgba(245, 197, 66, 0); + transform: translateY(0); + } +} + +#splitSceneBtn.split-scene-btn-flash { + animation: split-scene-btn-flash 180ms ease-in-out 2; +} + +/* Metadata tooltip on hover over filmstrip filename */ +.filmstrip-card .filmstrip-tooltip { + display: none; + position: absolute; + bottom: 100%; + left: 50%; + transform: translateX(-50%); + background: rgba(10,12,15,0.96); + border: 1px solid #2a3040; + border-radius: 8px; + padding: 8px 10px; + font-size: 11px; + color: #cbd2dc; + white-space: nowrap; + z-index: 100; + pointer-events: none; + box-shadow: 0 4px 12px rgba(0,0,0,.5); +} +.filmstrip-card:hover .filmstrip-tooltip { + display: block; +} + +.scene-zoom-topbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + margin-bottom: 6px; + padding: 6px 8px; + border-radius: 8px; + border: 1px solid #1f2533; + background: #10131a; + font-size: 11px; +} diff --git a/analyzer/css/dialogs/settings.css b/analyzer/css/dialogs/settings.css new file mode 100644 index 00000000..c7d1e1c6 --- /dev/null +++ b/analyzer/css/dialogs/settings.css @@ -0,0 +1,104 @@ +/* Settings dialog: larger, flex column with sticky footer */ +dialog.dlg-settings { + width: min(660px, 96vw); + max-width: min(660px, 96vw); + height: min(860px, 90vh); + max-height: min(860px, 90vh); + overflow: hidden; + padding: 0; +} +/* Only apply flex layout when the dialog is actually open — + otherwise display:flex overrides the browser's native display:none + that keeps closed elements hidden. */ +dialog.dlg-settings[open] { + display: flex; + flex-direction: column; +} + +.settings-body { + flex: 1 1 auto; + overflow-y: auto; + overflow-x: hidden; + padding: 16px; + display: grid; + gap: 10px; +} + +.settings-footer { + flex: 0 0 auto; + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 10px 16px; + border-top: 1px solid #1d2230; + background: var(--panel); +} + +/* Yellow Save button when settings have unsaved changes */ +#settingsSave.dirty { + background: #c9ae34; + border-color: #d4b72e; + color: #1a1a1a; +} +#settingsSave.dirty:hover { + background: #d4ba38; +} + +/* Reuse dialog styling for settings */ +.settings { + display: grid; + gap: 10px; + padding: 12px; + width: min(560px, 96vw); +} + +.row { + display: flex; + gap: 8px; + align-items: center; +} + +.row label { + min-width: 160px; + color: var(--muted); + font-size: 13px; +} + +.row input[type="text"] { + flex: 1; +} + +.row select { + width: 200px; +} + +#ratingProfile { + min-width: 0; + width: 100%; + max-width: 100%; + flex: 1; +} + +/* Rating stars */ +.stars { + display: flex; + gap: 4px; + align-items: center; + user-select: none; +} + +.star { + font-size: 16px; + line-height: 1; + cursor: pointer; + color: #2a3040; + /* No color transition — firing a transition on 5 stars × N cards during + mouse movement adds up quickly */ +} + +.star.filled.auto { + color: #6aa0ff; +} + +.stars .star.manual.filled { color: #f5c542; } +.stars .star.auto.filled { color: #6aa0ff; } diff --git a/analyzer/css/dialogs/tutorial-chooser.css b/analyzer/css/dialogs/tutorial-chooser.css new file mode 100644 index 00000000..0f0a690b --- /dev/null +++ b/analyzer/css/dialogs/tutorial-chooser.css @@ -0,0 +1,106 @@ +/* ═══ Tutorial chooser dialog ═══ */ +dialog.tut-chooser-dlg { + width: min(560px, 96vw); + max-width: min(560px, 96vw); + padding: 0; + border-radius: 14px; +} +.tut-chooser-body { + padding: 22px 24px 18px; + display: flex; + flex-direction: column; + gap: 14px; +} +.tut-chooser-title { + margin: 0; + font-size: 17px; + font-weight: 700; + color: var(--text); +} +.tut-chooser-sub { + margin: 0; + font-size: 12px; + color: var(--muted); +} +.tut-chooser-choices { + display: flex; + flex-direction: column; + gap: 10px; +} +.tut-chooser-choice { + display: flex; + align-items: flex-start; + gap: 14px; + width: 100%; + min-height: 0; + height: auto; + padding: 14px 16px; + background: #10141c; + border: 1px solid #2a3040; + border-radius: 10px; + text-align: left; + color: var(--text); + cursor: pointer; + white-space: normal; + font: inherit; + line-height: 1.4; + transition: border-color .15s, background .15s, transform .1s; +} +.tut-chooser-choice:hover { + border-color: var(--brand); + background: #141a24; +} +.tut-chooser-choice:active { + transform: translateY(1px); +} +.tut-chooser-choice.primary { + border-color: rgba(74, 163, 255, 0.5); + background: rgba(74, 163, 255, 0.06); +} +.tut-chooser-choice.primary:hover { + background: rgba(74, 163, 255, 0.12); + border-color: var(--brand); +} +.tut-chooser-icon { + flex: 0 0 36px; + font-size: 28px; + line-height: 1; + text-align: center; + padding-top: 2px; +} +.tut-chooser-text { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 auto; + min-width: 0; +} +.tut-chooser-label { + display: block; + font-weight: 700; + font-size: 14px; + color: var(--text); +} +.tut-chooser-hint { + display: block; + font-size: 12px; + color: var(--muted); + line-height: 1.5; + font-weight: 400; + white-space: normal; +} +.tut-chooser-foot { + display: flex; + justify-content: flex-end; + padding-top: 4px; +} +.tut-chooser-cancel { + background: none; + border: 1px solid #333; + color: var(--muted); + border-radius: 6px; + padding: 6px 14px; + font-size: 12px; + cursor: pointer; +} +.tut-chooser-cancel:hover { border-color: #555; color: var(--text); } diff --git a/analyzer/css/folder-tree.css b/analyzer/css/folder-tree.css new file mode 100644 index 00000000..fa382ea2 --- /dev/null +++ b/analyzer/css/folder-tree.css @@ -0,0 +1,284 @@ +/* Folder tree panel */ +.folder-tree-wrap { + border-top: 1px solid #1d2230; + padding-top: 8px; + display: flex; + flex-direction: column; + gap: 6px; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +/* Empty state: tree visible but greyed out before any folder is opened */ +.folder-tree-wrap.folder-tree-empty { + opacity: 0.45; + pointer-events: none; +} +.folder-tree-wrap.folder-tree-empty #folderTree::before { + content: 'Open a folder to see the tree'; + display: block; + padding: 12px 8px; + font-size: 11px; + color: var(--muted); + text-align: center; + font-style: italic; +} + +.folder-tree-header { + display: flex; + align-items: center; + gap: 6px; +} + +.folder-tree-header .tree-title { + font-size: 12px; + color: var(--muted); + letter-spacing: .3px; + flex: 1 1 auto; +} + +.folder-tree-header button { + padding: 2px 7px; + font-size: 11px; + height: auto; +} + +#folderTree { + flex: 1 1 auto; + min-height: 0; + max-height: none; + overflow-x: hidden; + overflow-y: auto; + background: #0a0d12; + border: 1px solid #1d2230; + border-radius: 8px; + padding: 4px 2px; +} + +/* Lock the sidebar width so long folder names can't blow it out */ +.folder-tree-wrap { + max-width: 100%; +} + +/* ── Tree node layout: consistent flex rows ──────────────────── */ +.tree-node-row, .adlg-node-row { + display: flex; + align-items: center; + gap: 3px; + padding: 2px 5px; + border-radius: 4px; + cursor: default; + min-height: 20px; +} +.tree-node-row:hover { background: rgba(255,255,255,.04); } +.adlg-node-row:hover { background: rgba(255,255,255,.04); } +/* Expand arrow: fixed 12px slot, always present; leaf = invisible */ +.tree-arrow { + flex: 0 0 12px; + width: 12px; + font-size: 9px; + text-align: center; + line-height: 1; + cursor: pointer; + color: #6a7585; + user-select: none; + transition: transform .12s; +} +.tree-arrow.open { transform: rotate(90deg); } +.tree-arrow.leaf { visibility: hidden; cursor: default; } +/* Folder icon: alignment anchor */ +.tree-icon { flex: 0 0 18px; font-size: 14px; text-align: center; } +/* Invisible spacer matching tree-cb width (sidebar rows without a checkbox) */ +.tree-cb-spacer { flex: 0 0 13px; width: 13px; height: 1px; display: inline-block; } +/* Greyed-out folder: Kestrel found no supported photos */ +/* Deep: folder AND every descendant have 0 images — fade whole row */ +.adlg-node-row.no-photos-deep { opacity: 0.38; } +.adlg-node-row.no-photos-deep .adlg-cb { cursor: not-allowed; pointer-events: none; } +/* Shallow: folder itself has 0 images but a descendant has some — fade checkbox only */ +.adlg-node-row.no-photos-shallow .adlg-cb { opacity: 0.38; cursor: not-allowed; pointer-events: none; } +/* Legacy class kept for backwards compat */ +.adlg-node-row.no-photos { opacity: 0.38; } +.adlg-node-row.no-photos .adlg-cb { cursor: not-allowed; pointer-events: none; } + +.tree-label { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 12px; +} + +.tree-count { + margin-left: 8px; + color: var(--muted); + font-size: 12px; + flex-shrink: 0; +} + +.tree-node-row.has-kestrel:not(.analyzed-full):not(.analyzed-partial):not(.analyzed-none) .tree-label { + color: var(--ok); + cursor: pointer; + font-weight: 600; +} + +.tree-node-row.has-kestrel:not(.analyzed-full):not(.analyzed-partial):not(.analyzed-none):hover .tree-label { + color: #c8dcf5; +} + +/* Analysis completion states */ +.tree-node-row.analyzed-full .tree-label { + color: var(--ok); + cursor: pointer; + font-weight: 700; +} + +.tree-node-row.analyzed-partial .tree-label { + color: #b36bff; + cursor: pointer; + font-weight: 700; +} + +.tree-node-row.analyzed-none .tree-label { + color: var(--brand); + cursor: pointer; + font-weight: 700; +} + +/* Mirror classes for analyze dialog rows */ +.adlg-node-row.analyzed-full .tree-label { + color: var(--ok); + font-weight: 700; +} + +.adlg-node-row.analyzed-partial .tree-label { + color: #b36bff; + font-weight: 700; +} + +.adlg-node-row.analyzed-none .tree-label { + color: var(--brand); + font-weight: 700; +} + +/* Mirror has-kestrel styling for analyze dialog rows */ +.adlg-node-row.has-kestrel:not(.analyzed-full):not(.analyzed-partial):not(.analyzed-none) .tree-label { + color: var(--ok); + cursor: pointer; + font-weight: 600; +} + +.adlg-node-row.has-kestrel:not(.analyzed-full):not(.analyzed-partial):not(.analyzed-none):hover .tree-label { + color: #c8dcf5; +} + +/* Outdated version styling (analyzed on an earlier Kestrel version) */ +.tree-node-row.version-outdated .tree-label { + color: var(--ok); + cursor: pointer; + font-weight: 600; + font-style: italic; +} +.adlg-node-row.version-outdated .tree-label { + color: var(--ok); + font-weight: 600; + font-style: italic; +} + +/* Errored-images styling: light-red tint + left border. Composes with + analyzed-partial / analyzed-full so the row still picks up its primary + color via the .tree-label rules. */ +.tree-node-row.has-errored-images, +.adlg-node-row.has-errored-images { + background: rgba(231, 76, 60, 0.08); + border-left: 3px solid var(--bad); + padding-left: 6px; +} +.tree-node-row.has-errored-images .tree-count, +.adlg-node-row.has-errored-images .tree-count { + color: var(--bad); +} + +/* In-progress analysis styling (subtle purple indicator with text) */ +.tree-node-row.in-progress { + background: rgba(179, 153, 204, 0.08); + border-left: 3px solid #b399cc; + padding-left: 6px; +} +.tree-node-row.in-progress .tree-label { + color: #d4a3ff !important; + font-style: italic; + font-weight: 700; +} +.tree-node-row.in-progress .tree-label::after { + content: ' (analysis in progress)'; + margin-left: 6px; + font-size: 0.9em; + opacity: 0.85; +} + +/* Context menu for folder tree */ +.kestrel-ctx-menu { + position: fixed; + z-index: 9999; + background: #23272e; + border: 1px solid #444; + border-radius: 6px; + padding: 4px 0; + min-width: 220px; + box-shadow: 0 4px 16px rgba(0,0,0,0.5); +} +.kestrel-ctx-menu-item { + padding: 7px 16px; + cursor: pointer; + font-size: 13px; + color: #ddd; + white-space: nowrap; +} +.kestrel-ctx-menu-item:hover { + background: #3a3f4b; + color: #fff; +} +.kestrel-ctx-menu-item.danger { + color: #e74c3c; +} +.kestrel-ctx-menu-item.danger:hover { + background: #5a2020; + color: #ff6b6b; +} + +.tree-node-row.no-kestrel { + opacity: 0.38; +} + +/* Bold non-analyzed folder labels for better visibility on all platforms */ +.tree-node-row.no-kestrel .tree-label { + font-weight: 700 !important; + color: #cbd2dc !important; +} + +.tree-node-row.active .tree-label { + color: #fff; + font-weight: 700; +} + +.tree-node-row.active { + background: #10293d; +} + +/* Visual connector lines from parent to children */ +.tree-children { + padding-left: 12px; + border-left: 1px solid #1e2840; + margin-left: 8px; +} + +.tree-cb { + flex: 0 0 13px; + width: 13px; + height: 13px; + margin: 0; + cursor: pointer; + accent-color: #3a84e6; +} diff --git a/analyzer/css/grid.css b/analyzer/css/grid.css new file mode 100644 index 00000000..b958fd1d --- /dev/null +++ b/analyzer/css/grid.css @@ -0,0 +1,720 @@ +/* Grid */ +.grid { + display: grid; + gap: 12px; + padding: 16px; + grid-template-columns: repeat(auto-fill, minmax(240px, 1fr)); +} + +/* When bird-crop thumbs are shown, widen the track so the main thumb + keeps the same (no-crop) height and the crop fits into the extra + width. 240px main + 6px gap + ~150px square crop ≈ 400px. */ +.grid.grid--bird-thumbs { + grid-template-columns: repeat(auto-fill, minmax(400px, 1fr)); +} + +.card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 12px; + /* overflow:clip clips border-radius without creating a stacking context */ + overflow: clip; + display: flex; + flex-direction: column; + /* content-visibility:auto lets the browser skip ALL rendering work (style, + layout, paint) for cards that are off-screen. This is the single biggest + scroll-perf win when hundreds/thousands of scene cards are in the DOM: + each image-load paint invalidation stays scoped to the visible card + instead of triggering an O(n) walk across every card in the grid. + contain-intrinsic-size gives the grid a stable placeholder height so + scroll position and scrollbar size stay correct. */ + content-visibility: auto; + contain-intrinsic-size: auto 280px; +} + +.card:hover { + /* Simple border tint — no transform (avoids GPU layer promotion/demotion + on every card the mouse crosses) and no background repaint */ + border-color: #3a4a6a; +} + +.thumb { + aspect-ratio: 16/10; + background: #0e1117; + /* overflow:hidden is fine here — .thumb is a leaf container with no stacking + context concerns, and it correctly clips the image to the card's top corners */ + overflow: hidden; + position: relative; +} + +/* Full scene + bird crop side by side (same row height as the main 16:10 thumb). */ +.thumb-row { + --thumb-row-gap: 6px; + display: grid; + grid-template-columns: minmax(0, 1fr) calc((100% - var(--thumb-row-gap)) * 10 / 26); + gap: var(--thumb-row-gap); + width: 100%; + min-width: 0; +} + +.thumb-row > .thumb { + min-width: 0; +} + +.thumb-row > .thumb-bird-crop { + width: 100%; + min-width: 0; +} + +.thumb img { + width: 100%; + height: 100%; + object-fit: contain; + display: block; +} + +.card .body { + padding: 10px 12px 14px; + display: grid; + gap: 6px; +} + +.meta { + display: flex; + justify-content: space-between; + align-items: center; + color: var(--muted); + font-size: 12px; + white-space: normal; + gap: 8px; +} + +.meta> :first-child { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + text-overflow: ellipsis; +} + +.chips { + display: flex; + gap: 6px; + flex-wrap: wrap; +} + +.chip { + background: var(--chip); + border: 1px solid #2a3040; + padding: 2px 8px; + border-radius: 999px; + font-size: 12px; + color: #cbd2dc; +} + +.chip.badge { + background: #10293d; + border-color: #1d3e5e; + color: #a9c9ee; +} + +.chip.manual-approved, +.edit-chip.manual-approved { + background: rgba(245, 197, 66, 0.10); + border-color: rgba(183, 145, 37, 0.65); + color: #e9d6a1; +} + +.scene-approved { + box-shadow: inset 0 0 0 1px rgba(245, 197, 66, 0.14); +} + +.scene-approved .thumb { + outline: 1px solid rgba(245, 197, 66, 0.10); + outline-offset: -1px; +} + +.scene-approved .thumb-bird-crop { + outline: 1px solid rgba(245, 197, 66, 0.10); + outline-offset: -1px; +} + +.thumb-bird-crop { + position: relative; + aspect-ratio: 1; + border-radius: 6px; + border: 2px solid rgba(255, 255, 255, 0.25); + overflow: hidden; + background: #0e1117; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5); +} +.thumb-bird-crop img { + width: 100%; + height: 100%; + object-fit: cover; + display: block; +} + +.scene-approved-badge { + margin-left: 6px; +} + +.approval-note { + color: #f7dda0; + font-weight: 600; +} + +.mark-reviewed-btn { + cursor: pointer; + background: rgba(245, 197, 66, 0.08); + border: 1px solid rgba(245, 197, 66, 0.3); + color: #f7dda0; + font-size: 11px; + font-weight: 600; + padding: 2px 10px; + border-radius: 12px; + white-space: nowrap; + line-height: 1.4; +} +.mark-reviewed-btn:hover { + background: rgba(245, 197, 66, 0.18); + border-color: rgba(245, 197, 66, 0.5); +} + +/* Improve chip rendering & truncation */ +.chip { + display: inline-flex; + align-items: center; + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Slight breathing room on meta row */ +.meta { + padding-right: 2px; +} + +/* Pill for score in meta rows to prevent clipping */ +.meta .score { + display: inline-flex; + align-items: center; + gap: 4px; + background: #10293d; + border: 1px solid #1d3e5e; + color: #a9c9ee; + padding: 2px 8px; + border-radius: 999px; +} + +/* Further anti-clipping & wrapping safeguards */ +.meta { + flex-wrap: wrap; +} + +.meta .score { + margin-left: auto; +} + +.chips { + min-width: 0; +} + +.chip { + max-width: 100%; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.filename { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +/* Editable species chips in scene edit mode */ +.edit-chips { + display: flex; + gap: 6px; + flex-wrap: wrap; + align-items: center; +} +.edit-chip { + display: inline-flex; + align-items: center; + gap: 4px; + background: var(--chip); + border: 1px solid #2a3040; + padding: 3px 8px; + border-radius: 999px; + font-size: 12px; + color: #cbd2dc; +} +.edit-chip .chip-x { + cursor: pointer; + color: #e06060; + font-weight: bold; + font-size: 13px; + margin-left: 2px; + line-height: 1; +} +.edit-chip .chip-x:hover { color: #ff8080; } + +/* Inline chip input */ +.chip-input-wrap { + display: inline-flex; + align-items: center; + background: #1a2a44; + border: 1px solid var(--brand); + border-radius: 999px; + padding: 1px 4px 1px 8px; + margin-left: 4px; +} +.chip-input { + background: transparent; + border: none; + color: #fff; + font-size: 12px; + outline: none; + width: 100px; +} +.chip-commit-btn { + background: var(--brand); + border: none; + color: #fff; + border-radius: 50%; + width: 18px; + height: 18px; + display: flex; + align-items: center; + justify-content: center; + cursor: pointer; + font-size: 10px; + margin-left: 4px; +} +.chip-commit-btn:hover { background: #56afff; } + +/* Custom species combobox dropdown. + Uses position: fixed (instead of absolute relative to .chip-input-wrap) + to escape the overflow-x: auto clipping on .scene-topbar-tags. The + coordinates are set in JS via getBoundingClientRect on the input. */ +.chip-input-wrap--species { position: relative; } +.chip-input-dropdown { + position: fixed; + background: #1a2a44; + border: 1px solid var(--brand); + border-radius: 8px; + z-index: 9999; + min-width: 240px; + max-width: 360px; + max-height: 280px; + overflow-y: auto; + box-shadow: 0 6px 20px rgba(0, 0, 0, 0.55); + font-size: 12px; + padding: 4px 0; +} +.chip-combo-item { + display: flex; + justify-content: space-between; + align-items: center; + gap: 12px; + padding: 5px 10px; + cursor: pointer; + color: #fff; + white-space: nowrap; +} +.chip-combo-item--active, +.chip-combo-item:hover { + background: rgba(86, 175, 255, 0.28); +} +.chip-combo-name { font-weight: 500; } +.chip-combo-family { + color: #9ab; + font-size: 11px; + font-style: italic; + flex-shrink: 0; +} + +/* Reviewed tags golden styling */ +.reviewed-tags .chip { + background: rgba(245, 197, 66, 0.15); + border-color: rgba(245, 197, 66, 0.4); + color: #f5c542; +} +.reviewed-tags .chip .chip-x { color: #f5c542; opacity: 0.7; } +.reviewed-tags .chip .chip-x:hover { color: #e06060; opacity: 1; } +.chip-add-btn { + cursor: pointer; + background: transparent; + border: 1px dashed #3a84e6; + color: #6aa0ff; + border-radius: 999px; + padding: 3px 10px; + font-size: 12px; +} +.chip-add-btn:hover { background: #1a2a44; } +.edit-panel { + display: grid; + gap: 8px; + background: #10131a; + border: 1px solid #1f2533; + border-radius: 10px; + padding: 10px; + font-size: 13px; +} +.edit-panel label { color: var(--muted); font-size: 12px; } +.edit-section { display: grid; gap: 4px; } +/* Scene split mode: checkboxes on image cards */ +.card.split-mode { position: relative; } +.card.split-mode .split-check { + position: absolute; + top: 6px; + left: 6px; + z-index: 3; + width: 18px; + height: 18px; + accent-color: #3a84e6; +} +.card.split-selected { outline: 2px solid #3a84e6; outline-offset: -2px; border-radius: 10px; } +/* Yellow save button when dirty */ +#saveCsv:not(:disabled) { + background: #b59b2e; + color: #fff; + border-color: #d4b72e; +} +#saveCsv:not(:disabled):hover { + background: #c9ae34; +} + +/* Anti-clipping improvements */ +.meta { + gap: 8px; +} + +/* Removed invalid comma between overflow properties (was causing lint error) */ +/* .meta > :first-child duplicate rule retained earlier above */ +.inline input[type="text"] { + min-width: 0; +} + +.card .body { + padding-bottom: 14px; +} + +.card .body .meta { + justify-content: center; + gap: 14px; +} + +.title-score, +.title-count { + font-size: 12px; + flex-shrink: 0; +} + +.title-score { + color: #a9c9ee; +} + +.title-count { + color: var(--muted); +} + +/* Prevent text clipping with truncation */ +.title { + display: flex; + gap: 6px; + align-items: center; + min-width: 0; +} + +.title i.folder-name { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + flex: 0 1 auto; + min-width: 0; + max-width: 9em; + display: inline-block; + vertical-align: middle; + font-style: italic; +} + +.title .title-sep { + flex-shrink: 0; +} + +.title b { + flex-shrink: 0; +} + +.title .name { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +.title-score { + font-size: 12px; + flex-shrink: 0; + color: #a9c9ee; + background: #10293d; + border: 1px solid #1d3e5e; + padding: 1px 7px; + border-radius: 999px; +} + +.title-count { + font-size: 12px; + flex-shrink: 0; + color: var(--muted); +} + +.meta .truncate { + flex: 1 1 auto; + min-width: 0; + overflow: hidden; + white-space: nowrap; + text-overflow: ellipsis; +} + +/* Sticky folder group headers */ +.folder-group-header { + position: sticky; + top: 0; + z-index: 10; + background: var(--bg); +} + +/* Multi-select: selected scene card */ +.card.selected { + background: #1a2540 !important; + border-color: #3a84e6 !important; + outline: 2px solid rgba(58, 132, 230, .5); +} + +.card.selected:hover { + background: #1f2d4e !important; +} + +/* Keyboard-focused scene card (after closing scene dialog or arrow-key nav) */ +.card.focused { + outline: 2px solid rgba(58, 132, 230, .7); + outline-offset: 2px; + box-shadow: 0 0 12px rgba(58, 132, 230, .3); +} + +/* Folder group headers in scene grid */ +.folder-group { + margin-bottom: 4px; +} + +.folder-group-header { + display: flex; + align-items: center; + gap: 8px; + padding: 7px 14px; + background: #161922; + border: 1px solid #222634; + border-radius: 8px; + cursor: pointer; + user-select: none; + font-size: 13px; + color: #cbd2dc; +} + +.folder-group-header:hover { + background: #1d2230; + border-color: #2a3040; +} + +.folder-group-toggle { + font-size: 10px; + flex-shrink: 0; + transition: transform .15s; + color: var(--muted); +} + +.folder-group-header.collapsed .folder-group-toggle { + transform: rotate(-90deg); +} + +.folder-group-name { + font-weight: 600; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + min-width: 0; + flex: 0 1 auto; /* shrinks if needed, does not grow — spacer div handles remaining space */ + max-width: 320px; +} + +.folder-group-count { + flex-shrink: 0; + font-size: 12px; +} + +.folder-group-left-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.folder-group-right-actions { + display: flex; + gap: 4px; + flex-shrink: 0; +} + +.culling-btn { + margin-left: auto; + padding: 3px 12px; + font-size: 12px; + background: #1a2540; + color: #7eb8e0; + border: 1px solid #2a3b5a; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + transition: background .15s, border-color .15s; +} +.culling-btn:hover { + background: #243456; + border-color: #3a84e6; + color: #b8daf0; +} +.write-metadata-btn { + margin-left: 6px; + padding: 3px 12px; + font-size: 12px; + background: #1a2a1e; + color: #7ed4a0; + border: 1px solid #2a5a3b; + border-radius: 6px; + cursor: pointer; + white-space: nowrap; + transition: background .15s, border-color .15s; +} +.write-metadata-btn:hover { + background: #243d2c; + border-color: #3ae67a; + color: #b0f0c8; +} + +.folder-group-grid.hidden { + display: none; +} + +/* Floating multi-select action bar */ +.select-action-bar { + position: fixed; + bottom: 28px; + left: 50%; + transform: translateX(-50%); + background: #1d2230; + border: 1px solid #3a84e6; + border-radius: 12px; + padding: 10px 18px; + display: flex; + gap: 14px; + align-items: center; + box-shadow: 0 6px 32px rgba(0, 0, 0, .6); + z-index: 300; + white-space: nowrap; +} + +.select-action-bar.hidden { + display: none; +} + +/* Final override: allow wrapping in meta rows (was nowrap earlier) */ +.meta { + white-space: normal; +} + +.meta>* { + min-width: 0; +} + +/* Thumbnail badges (quality & image count) */ +.thumb .badge { + position: absolute; + top: 8px; + left: 8px; + background: #10293d; + border: 1px solid #1d3e5e; + color: #a9c9ee; + padding: 4px 8px; + border-radius: 999px; + font-size: 12px; + display: inline-flex; + align-items: center; + gap: 6px; + box-shadow: 0 2px 6px rgba(0, 0, 0, .4); +} + +.thumb .badge.count { + left: auto; + right: 8px; +} + +/* Title-level badges (placed under title, right-aligned) */ +.title-row { + display: flex; + align-items: center; + gap: 8px; +} + +.title-row .title { + flex: 1 1 auto; + min-width: 0; +} + +.title-badges { + display: flex; + align-items: center; + gap: 8px; + color: var(--muted); + font-size: 12px; + flex-shrink: 0; +} + +.title-badges .score { + background: #10293d; + border: 1px solid #1d3e5e; + color: #a9c9ee; + padding: 1px 7px; + border-radius: 999px; +} + +/* Folder body collapse */ +.folder-group-body.hidden { + display: none; +} + +/* Scroll position indicator (floating overlay on right edge of main view) */ +#scrollPositionIndicator { + position: fixed; + right: 22px; + top: 50%; + transform: translateY(-50%); + background: rgba(10, 12, 15, 0.88); + border: 1px solid #2a3040; + color: var(--text); + font-size: 11px; + font-weight: 600; + padding: 5px 10px; + border-radius: 6px; + max-width: 220px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; + opacity: 0; + pointer-events: none; + z-index: 9000; + transition: opacity 0.25s ease; + box-shadow: 0 3px 12px rgba(0,0,0,0.5); +} diff --git a/analyzer/css/layout.css b/analyzer/css/layout.css new file mode 100644 index 00000000..fb0e6ecd --- /dev/null +++ b/analyzer/css/layout.css @@ -0,0 +1,85 @@ +.app-body { + display: flex; + flex: 1 1 auto; + min-height: 0; +} + +header { + position: relative; + z-index: 5; + width: 400px; + flex: 0 0 400px; + height: 100%; + background: linear-gradient(180deg, rgba(17, 19, 24, .95), rgba(17, 19, 24, .85)); + backdrop-filter: blur(8px); + border-right: 1px solid #1d2230; + overflow: hidden; + display: flex; + flex-direction: column; +} + +/* Sidebar drag-resize handle */ +.sidebar-resizer { + width: 5px; + flex: 0 0 5px; + height: 100%; + background: #1d2230; + cursor: col-resize; + z-index: 6; + transition: background .15s; +} +.sidebar-resizer:hover, +.sidebar-resizer.dragging { + background: #3a84e6; +} + +.wrap { + max-width: none; + margin: 0; + padding: 16px; + height: 100%; + display: flex; + flex-direction: column; + gap: 12px; + overflow: hidden; +} + +.spacer { + flex: 0 0 auto; +} + +h1 { + font-size: 20px; + margin: 0 0 8px; + font-weight: 700; + letter-spacing: .2px; +} + +main { + max-width: none; + flex: 1 1 auto; + min-width: 0; + height: 100%; + overflow: auto; +} + +#mainZoom { + transform-origin: top left; + width: 100%; + height: 100%; +} + +/* Column divider between grid and preview panel */ +.divider { + width: var(--divider-w); + background: #1f2533; + cursor: col-resize; +} + +.divider:hover { + background: #2a3040; +} + +.divider:active { + background: #3a84e6; +} diff --git a/analyzer/css/legal-banner.css b/analyzer/css/legal-banner.css new file mode 100644 index 00000000..145ae172 --- /dev/null +++ b/analyzer/css/legal-banner.css @@ -0,0 +1,72 @@ +/* ── Legal Notice Banner ───────────────────────────────────────── */ +.legal-notice-banner { + position: fixed; + top: 0; + left: 0; + right: 0; + background: #1d5aa8; + color: #fff; + padding: 10px 20px; + z-index: 100000; + display: flex; + align-items: center; + justify-content: center; + gap: 20px; + flex-wrap: wrap; + font-size: 13px; + font-weight: 500; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.5); +} + +.legal-notice-banner.hidden { + display: none; +} + +.legal-notice-msg { + max-width: 760px; + line-height: 1.4; +} + +.legal-notice-actions { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; +} + +.legal-notice-banner a, +.legal-notice-link { + color: #fff; + text-decoration: underline; + font-weight: 700; +} + +.legal-notice-link { + padding: 4px 10px; + border: 1px solid rgba(255, 255, 255, 0.6); + border-radius: 6px; + text-decoration: none; + font-size: 12px; + transition: background 0.15s, border-color 0.15s; +} + +.legal-notice-link:hover { + background: rgba(255, 255, 255, 0.12); + border-color: #fff; +} + +.legal-notice-btn { + background: #fff; + color: #1d5aa8; + border: none; + padding: 4px 12px; + border-radius: 6px; + font-weight: 700; + cursor: pointer; + font-size: 12px; + transition: opacity 0.2s; +} + +.legal-notice-btn:hover { + opacity: 0.9; +} diff --git a/analyzer/css/status-bar.css b/analyzer/css/status-bar.css new file mode 100644 index 00000000..afc276d0 --- /dev/null +++ b/analyzer/css/status-bar.css @@ -0,0 +1,129 @@ +.status { + color: var(--muted); + padding: 8px 2px; + font-size: 13px; +} + +.version-badge { + color: var(--muted); + padding: 4px 2px 8px; + font-size: 12px; +} + +/* Folder-load progress bar */ +.load-progress { + display: flex; + flex-direction: column; + gap: 5px; + padding: 4px 2px 2px; +} + +.load-progress-label { + font-size: 12px; + color: var(--muted); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.load-progress-track { + height: 4px; + background: #1d2230; + border-radius: 999px; + overflow: hidden; +} + +.load-progress-fill { + height: 100%; + width: 0%; + background: #3a84e6; + border-radius: 999px; + transition: width .18s ease; +} + +/* ── Bottom status bar ── */ +.app-status-bar { + flex: 0 0 auto; + display: flex; + align-items: center; + gap: 12px; + padding: 3px 14px; + background: #111318; + border-top: 1px solid #1d2230; + font-size: 12px; + color: var(--muted); + z-index: 10; + min-height: 26px; +} + +.app-status-bar .status { + padding: 0; + font-size: 12px; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.app-status-bar .load-progress { + display: flex; + align-items: center; + gap: 6px; + padding: 0; + flex-direction: row; +} + +.app-status-bar .load-progress-label { + font-size: 11px; + white-space: nowrap; +} + +.app-status-bar .load-progress-track { + width: 120px; + height: 3px; +} + +.app-status-bar .version-badge { + padding: 0; + font-size: 11px; + margin-left: auto; + white-space: nowrap; +} + +.sidebar-footer { + display: flex; + gap: 6px; + align-items: center; + justify-content: flex-start; + flex-wrap: wrap; + flex: 0 0 auto; +} + +/* Circular zoom buttons */ +#zoomOut, #zoomIn { + width: 28px; + height: 28px; + min-width: 28px; + border-radius: 50%; + padding: 0; + display: inline-flex; + align-items: center; + justify-content: center; + font-size: 16px; + font-weight: 600; + line-height: 1; + flex-shrink: 0; +} + +/* Sidebar footer: uniform small button styles */ +.sidebar-footer button { + height: 28px; + font-size: 12px; + padding: 0 10px; + border-radius: 7px; +} +.sidebar-footer #zoomOut, +.sidebar-footer #zoomIn { + padding: 0; + width: 28px; + height: 28px; +} diff --git a/analyzer/css/timeline.css b/analyzer/css/timeline.css new file mode 100644 index 00000000..8bb82916 --- /dev/null +++ b/analyzer/css/timeline.css @@ -0,0 +1,134 @@ +/* Timeline action buttons (used outside timeline view too) */ +.timeline-actions { + display: flex; + gap: 10px; + margin-top: 10px; +} +.action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: #1a202e; + border: 1px solid #2a344d; + color: #cbd2dc; + padding: 6px 12px; + border-radius: 6px; + font-size: 12px; + cursor: pointer; + transition: all 0.15s ease; +} +.action-btn:hover { + background: #252d40; + border-color: #3a4a6d; + color: #fff; +} +.action-btn i { + font-style: normal; + font-size: 14px; +} + +.action-btn i { + font-style: normal; + font-size: 14px; +} + +/* ---- Timeline view (group-by-capture-time) ---- */ +.timeline-body { + padding: 4px 0 8px 0; +} + +.timeline-day-banner { + position: sticky; + top: 44px; /* below folder-group-header */ + z-index: 8; + font-size: 11px; + font-weight: 700; + letter-spacing: 0.06em; + text-transform: uppercase; + color: #6a7a90; + padding: 10px 16px 6px 16px; + background: var(--bg); + border-bottom: 1px solid #1a2040; + margin-bottom: 4px; +} + +.timeline-node { + display: flex; + align-items: stretch; + gap: 0; +} + +.timeline-rail-col { + display: flex; + flex-direction: column; + align-items: center; + flex-shrink: 0; + width: 44px; + padding-top: 0; +} + +/* Dot size is set inline by JS to reflect image count in the cluster, + so bursts of activity are visible at a glance while scrolling down + the rail. Bigger dot = more images in that cluster. */ +.timeline-dot { + width: 11px; + height: 11px; + border-radius: 50%; + background: #2e5ea0; + border: 2px solid var(--bg); + box-shadow: 0 0 0 3px rgba(58, 100, 200, 0.18); + flex-shrink: 0; + margin-top: 13px; + z-index: 1; + transition: transform .15s ease, box-shadow .15s ease; +} +.timeline-node:hover .timeline-dot { + box-shadow: 0 0 0 3px rgba(106, 160, 255, 0.32); +} + +.timeline-line { + width: 2px; + background: #1c2438; + flex: 1; + min-height: 16px; + margin-top: 3px; +} + +.timeline-line.last { + background: transparent; +} + +.timeline-content-col { + flex: 1; + min-width: 0; + padding: 0 12px 20px 4px; +} + +.timeline-node-header { + display: flex; + align-items: baseline; + gap: 10px; + padding: 8px 0 8px 0; + border-bottom: 1px solid #141a28; + margin-bottom: 10px; +} + +.timeline-node-time { + font-size: 13px; + font-weight: 600; + color: #9ab4cc; +} + +.timeline-node-count { + font-size: 11px; +} + +.timeline-node-untimed .timeline-dot { + background: #4a5266; + box-shadow: 0 0 0 3px rgba(74,82,102,0.18); +} + +/* Inside a timeline node, cards are a bit denser */ +.timeline-grid { + gap: 10px; +} diff --git a/analyzer/css/toolbar.css b/analyzer/css/toolbar.css new file mode 100644 index 00000000..34d6178d --- /dev/null +++ b/analyzer/css/toolbar.css @@ -0,0 +1,221 @@ +.toolbar { + display: flex; + flex-direction: column; + gap: 10px; + flex: 1 1 auto; + min-height: 0; + overflow: hidden; +} + +.toolbar-row { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; +} + +.toolbar-row.actions { + flex-direction: column; + align-items: stretch; + flex: 0 0 auto; +} + +/* Analyze + Settings on a shared second line */ +.actions-secondary-row { + display: flex; + gap: 6px; +} +.actions-secondary-row button { + white-space: nowrap; + font-size: 13px; +} + +.toolbar-row.filters { + flex-direction: column; + align-items: stretch; + flex: 0 0 auto; + /* Filters panel is outside .toolbar so it stays pinned regardless of tree visibility */ +} + +.toolbar-row.status { + justify-content: flex-start; +} + +.filter-panel { + width: 100%; +} + +.filter-panel { + display: flex; + gap: 10px; + align-items: center; + flex-wrap: wrap; + padding: 6px 8px; + border: 1px solid #1d2230; + border-radius: 10px; + background: #0e1117; +} + +.filter-panel .filter-title { + width: 100%; + text-align: center; + color: var(--muted); + font-size: 11px; + font-weight: 600; + text-transform: uppercase; + letter-spacing: .5px; +} + +.filter-panel .muted { + color: var(--text); +} + +.filter-divider { + width: 100%; + height: 1px; + background: #1d2230; + margin: 2px 0; +} + +/* Centre the confidence threshold row horizontally in the panel */ +.filter-conf-row { + width: 100%; + justify-content: center; +} + +.filter-sort-row { + display: flex; + align-items: center; + gap: 8px; + width: 100%; +} +.filter-sort-label { + font-size: 12px; + white-space: nowrap; + flex-shrink: 0; +} +.filter-sort-row select { + flex: 1; + height: 30px; + font-size: 12px; +} + +.filter-panel input[type="search"] { + flex: 1 1 100%; + width: 100%; +} + +/* Generic form-control styling (buttons, inputs, selects) */ +button, +select, +input[type="text"], +input[type="search"], +input[type="number"] { + background: #0e1117; + color: var(--text); + border: 1px solid #2a3040; + border-radius: 8px; + height: 36px; + padding: 0 12px; + outline: none; + transition: border-color .15s ease, background .15s ease; +} + +button { + cursor: pointer; + background: #173253; + border-color: #214c7d; +} + +button:hover { + background: #1c3a62; + border-color: #2b5f9e; +} + +button.primary { + background: #1d5aa8; + border-color: #2b6ec8; +} + +button.primary:hover { + background: #1f63b8; + border-color: #3a84e6; +} + +button[disabled] { + opacity: .6; + cursor: not-allowed; +} + +#saveCsv:not([disabled]) { + background: #f5c542; + border-color: #d1a735; + color: #1a1a1a; +} + +#saveCsv:not([disabled]):hover { + background: #ffd45f; + border-color: #e6c049; +} + +.help-tip { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + margin-left: 8px; + border-radius: 50%; + background: #0e1117; + border: 1px solid #2a3040; + color: #a9c9ee; + font-size: 12px; + line-height: 1; +} + +.tooltip-layer { + position: fixed; + min-width: 220px; + max-width: 320px; + background: #0e1117; + border: 1px solid #2a3040; + color: #cbd2dc; + padding: 8px 10px; + border-radius: 8px; + font-size: 12px; + white-space: normal; + opacity: 0; + pointer-events: none; + transform: translateY(4px); + transition: opacity .12s ease, transform .12s ease; + z-index: 1000; +} + +.tooltip-layer.visible { + opacity: 1; + transform: translateY(0); +} + +/* Inline form rows used by edit panels and small dialog forms */ +.inline { + display: flex; + gap: 8px; + align-items: center; + flex-wrap: wrap; +} + +.inline>* { + min-width: 0; +} + +.inline button { + height: 30px; + white-space: nowrap; +} + +.inline input[type="text"] { + height: 30px; + flex: 1 1 160px; + min-width: 0; +} diff --git a/analyzer/css/tutorial.css b/analyzer/css/tutorial.css new file mode 100644 index 00000000..680ce76f --- /dev/null +++ b/analyzer/css/tutorial.css @@ -0,0 +1,391 @@ +/* ---- Tutorial overlay ---- */ +#tutorialOverlay { + position: fixed; + inset: 0; + z-index: 30000; + pointer-events: none; + display: none; +} +#tutorialOverlay.active { + display: block; + /* pointer-events stays none so users can interact with UI underneath */ +} +#tutorialOverlay.has-backdrop { + background: rgba(0,0,0,0.6); + pointer-events: all; +} +#tutorialHighlight { + position: fixed; + border-radius: 8px; + box-shadow: 0 0 0 9999px rgba(0,0,0,0.50); + border: 2px solid var(--brand); + transition: top .25s ease, left .25s ease, width .25s ease, height .25s ease; + pointer-events: none; + z-index: 30001; + display: none; +} +#tutorialCard { + position: fixed; + background: #141820; + border: 1px solid var(--brand); + border-radius: 14px; + padding: 20px 22px 16px; + width: 380px; + max-width: calc(100vw - 32px); + box-shadow: 0 12px 48px rgba(0,0,0,0.7); + z-index: 30002; + pointer-events: all; + transition: top .25s ease, left .25s ease; +} +#tutorialCard h3 { + margin: 0 0 10px; + font-size: 15px; + font-weight: 700; + color: var(--brand); +} +.tutorial-counter { + font-size: 11px; + color: var(--muted); + margin-bottom: 4px; +} +.tutorial-progress { + display: flex; + gap: 5px; + margin-bottom: 12px; + align-items: center; +} +.tutorial-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #333; + transition: background .2s; + flex-shrink: 0; +} +.tutorial-dot.active { background: var(--brand); } +.tutorial-body { + font-size: 13px; + line-height: 1.6; + color: var(--text); + margin-bottom: 16px; +} +.tutorial-body kbd { + background: #222; + padding: 1px 5px; + border-radius: 3px; + font-family: inherit; + font-size: 12px; +} +.tutorial-actions { + display: flex; + gap: 8px; + align-items: center; +} +.tutorial-skip { + margin-right: auto; + color: var(--muted) !important; + font-size: 12px; + padding: 4px 10px; + background: none; + border: 1px solid #333; + border-radius: 6px; + cursor: pointer; +} +.tutorial-skip:hover { border-color: #555; } +.tut-btn { + padding: 5px 14px; + border-radius: 6px; + border: 1px solid #333; + background: #1a1e28; + color: var(--text); + cursor: pointer; + font-size: 13px; + font-weight: 600; + transition: background .15s, border-color .15s; +} +.tut-btn:hover { background: #1d2230; border-color: var(--brand); } +.tut-btn.primary { + background: #1a3a5a; + border-color: var(--brand); + color: var(--brand); +} +.tut-btn.primary:hover { background: #1e4a6e; } +.tut-btn:disabled { opacity: 0.4; cursor: default; } +/* Highlight-target pulsing animation for interactive steps */ +@keyframes pulse-highlight { + 0% { box-shadow: 0 0 0 0 rgba(74,163,255,0.6); } + 70% { box-shadow: 0 0 0 12px rgba(74,163,255,0); } + 100% { box-shadow: 0 0 0 0 rgba(74,163,255,0); } +} +.highlight-target { + z-index: 30001 !important; + position: relative !important; + animation: pulse-highlight 1.8s infinite; + border-color: var(--brand) !important; + pointer-events: auto !important; +} +/* Instruction nudge that appears below tutorial body for click-to-advance steps */ +.tutorial-nudge { + display: flex; + align-items: center; + gap: 6px; + background: rgba(74,163,255,0.12); + border: 1px solid rgba(74,163,255,0.25); + border-radius: 8px; + padding: 8px 12px; + font-size: 12px; + font-weight: 600; + color: var(--brand); + margin-bottom: 14px; + animation: pulse-nudge 2s ease-in-out infinite; +} +@keyframes pulse-nudge { + 0%, 100% { opacity: 0.8; } + 50% { opacity: 1; } +} +.tutorial-nudge::before { content: '\203A\00A0'; font-size: 14px; } +#helpBtnMain { + padding: 6px 10px; + height: auto; + width: auto; + border-radius: 6px; + border: 1px solid var(--muted); + background: rgba(74, 163, 255, 0.08); + color: var(--brand); + font-size: 12px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 4px; + flex-shrink: 0; + transition: all .15s; +} +#helpBtnMain:hover { + background: rgba(74, 163, 255, 0.15); + border-color: var(--brand); + color: var(--brand); +} + +/* ═══ Tutorial: custom-body cards (workflow + editor picker) ═══ */ +#tutorialCard.tut-card-workflow { + width: 560px; +} +#tutorialCard.tut-card-editor { + width: 480px; +} + +.tut-workflow-intro { + font-size: 12px; + color: var(--muted); + margin-bottom: 10px; +} +.tut-workflow-tabs { + display: flex; + gap: 4px; + margin-bottom: 10px; + border-bottom: 1px solid #1d2230; +} +.tut-wf-tab { + background: transparent; + border: none; + color: var(--muted); + font-size: 12px; + font-weight: 600; + padding: 8px 10px; + cursor: pointer; + border-bottom: 2px solid transparent; + margin-bottom: -1px; + transition: color .15s, border-color .15s; +} +.tut-wf-tab:hover { color: var(--text); } +.tut-wf-tab.active { + color: var(--brand); + border-bottom-color: var(--brand); +} +.tut-wf-panel { + display: none; + padding: 4px 0 2px; +} +.tut-wf-panel.active { display: block; } +.tut-wf-flow { + display: flex; + flex-wrap: wrap; + align-items: stretch; + justify-content: center; + gap: 6px; +} +.tut-wf-node { + flex: 1 1 90px; + min-width: 90px; + max-width: 150px; + background: #10141c; + border: 1px solid #2a3040; + border-radius: 8px; + padding: 10px 8px; + font-size: 12px; + font-weight: 600; + text-align: center; + color: var(--text); + line-height: 1.3; + display: flex; + flex-direction: column; + justify-content: center; + gap: 3px; +} +.tut-wf-node span { + font-weight: 600; + font-size: 12px; +} +.tut-wf-node small { + display: block; + color: var(--muted); + font-size: 10px; + font-weight: 400; + margin-top: 2px; +} +.tut-wf-caption { + margin-top: 12px; + font-size: 12px; + color: var(--text); + line-height: 1.5; + text-align: center; +} +.tut-wf-caption kbd { + background: #1a1f2b; + border: 1px solid #2a3040; + border-radius: 4px; + padding: 1px 5px; + font-size: 11px; + font-family: inherit; +} +.tut-wf-node.accent { + border-color: rgba(74, 163, 255, 0.55); + background: rgba(74, 163, 255, 0.08); +} +.tut-wf-node.highlight { + border-color: rgba(245, 197, 66, 0.55); + background: rgba(245, 197, 66, 0.07); +} +.tut-wf-node.ok { + border-color: rgba(88, 196, 120, 0.5); + background: rgba(88, 196, 120, 0.08); +} +.tut-wf-node.bad { + border-color: rgba(232, 90, 90, 0.45); + background: rgba(232, 90, 90, 0.08); +} +.tut-wf-arrow { + align-self: center; + color: var(--muted); + font-size: 18px; + font-weight: 700; + padding: 0 2px; +} +.tut-wf-branch { + display: flex; + flex-direction: column; + gap: 4px; + flex: 1 1 120px; + min-width: 110px; +} +.tut-workflow-hint { + margin-top: 10px; + font-size: 11px; + color: var(--muted); + line-height: 1.5; + border-top: 1px solid #1d2230; + padding-top: 8px; +} + +/* Editor picker inside tutorial card */ +.tut-editor-intro { + font-size: 12px; + color: var(--muted); + margin-bottom: 12px; + line-height: 1.5; +} +.tut-editor-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; +} +.tut-editor-btn { + display: flex; + align-items: center; + justify-content: center; + padding: 10px 12px; + background: #10141c; + border: 1px solid #2a3040; + border-radius: 8px; + color: var(--text); + cursor: pointer; + font-size: 13px; + font-weight: 600; + text-align: center; + transition: border-color .15s, background .15s; +} +.tut-editor-btn:hover { + border-color: var(--brand); + background: #141a24; +} +.tut-editor-btn.selected { + border-color: var(--brand); + background: rgba(74, 163, 255, 0.15); +} +.tut-editor-btn[disabled] { cursor: default; } +.tut-editor-icon { + font-size: 20px; + line-height: 1; + width: 24px; + text-align: center; + flex-shrink: 0; +} +.tut-editor-label { flex: 1 1 auto; } +.tut-editor-foot { + margin-top: 12px; + font-size: 11px; + color: var(--muted); + text-align: center; +} + +/* Loading indicator inside a tutorial card (shown while samples load) */ +.tut-loading { + display: flex; + align-items: center; + gap: 8px; + margin-top: 10px; + padding: 8px 10px; + background: rgba(74, 163, 255, 0.08); + border: 1px solid rgba(74, 163, 255, 0.25); + border-radius: 8px; + font-size: 12px; + font-weight: 600; + color: var(--brand); +} +.tut-spinner { + width: 12px; + height: 12px; + border-radius: 50%; + border: 2px solid rgba(74, 163, 255, 0.3); + border-top-color: var(--brand); + animation: tut-spin 0.8s linear infinite; + flex-shrink: 0; +} +@keyframes tut-spin { to { transform: rotate(360deg); } } + +/* Inline footer link inside a tutorial card (e.g. Basics → Advanced) */ +.tut-inline-foot { + margin-top: 12px; + padding-top: 10px; + border-top: 1px solid #1d2230; + font-size: 12px; + color: var(--muted); +} +.tut-inline-foot a { + color: var(--brand); + text-decoration: none; + font-weight: 600; +} +.tut-inline-foot a:hover { text-decoration: underline; } diff --git a/analyzer/css/welcome.css b/analyzer/css/welcome.css new file mode 100644 index 00000000..aa3b9ea9 --- /dev/null +++ b/analyzer/css/welcome.css @@ -0,0 +1,301 @@ +/* ── Welcome / Quick-Start Panel ───────────────────────────── */ +#welcomePanel { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + min-height: calc(100vh - 48px); + padding: 48px 32px; + gap: 28px; + text-align: center; +} + +#welcomePanel h2 { + font-size: 26px; + font-weight: 700; + letter-spacing: -.3px; + margin: 0; +} + +.welcome-subtitle { + font-size: 14px; + color: var(--muted); + margin: -12px 0 0; +} + +.welcome-cards { + display: flex; + gap: 20px; + flex-wrap: wrap; + justify-content: center; + max-width: 840px; + width: 100%; +} + +.welcome-card { + background: var(--card); + border: 1px solid var(--card-border); + border-radius: 14px; + padding: 22px 22px 18px; + max-width: 390px; + flex: 1 1 280px; + text-align: left; + display: flex; + flex-direction: column; + gap: 10px; +} + +.welcome-card-icon { + font-size: 26px; + line-height: 1; +} + +.welcome-card-title { + font-size: 15px; + font-weight: 600; +} + +.welcome-card p { + font-size: 13px; + color: var(--muted); + line-height: 1.55; + margin: 0; + flex: 1 1 auto; +} + +.welcome-action-btn { + display: inline-flex; + align-items: center; + gap: 6px; + background: #1d5aa8; + border: 1px solid #2b6ec8; + color: #e7e9ee; + border-radius: 8px; + padding: 8px 16px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: background .15s, border-color .15s; + align-self: flex-start; + user-select: none; +} + +.welcome-action-btn:hover { + background: #1f63b8; + border-color: #3a84e6; +} + +.welcome-tips { + display: flex; + flex-direction: column; + gap: 9px; + max-width: 720px; + width: 100%; +} + +.welcome-tip { + background: #0e1117; + border: 1px solid #1d2230; + border-radius: 10px; + padding: 10px 16px; + font-size: 13px; + color: #cbd2dc; + text-align: left; + display: flex; + gap: 10px; + align-items: flex-start; + line-height: 1.5; +} + +.welcome-tip-icon { + font-size: 15px; + flex-shrink: 0; + margin-top: 1px; +} + +.welcome-link { + color: var(--brand); + cursor: pointer; + text-decoration: underline; +} + +.welcome-link:hover { + color: #7bbfff; +} + +.welcome-footer { + font-size: 13px; + color: var(--muted); +} + +.welcome-footer a { + color: var(--brand); + text-decoration: none; +} + +.welcome-footer a:hover { + text-decoration: underline; +} + +/* ═══ Welcome panel: "What's New" banner ═══ */ +.welcome-whats-new { + width: 100%; + max-width: 840px; + background: linear-gradient(135deg, rgba(74, 163, 255, 0.12), rgba(74, 163, 255, 0.04)); + border: 1px solid rgba(74, 163, 255, 0.3); + border-radius: 12px; + padding: 14px 18px 14px 16px; + text-align: left; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.25); +} +.welcome-whats-new.hidden { display: none; } +.wwn-head { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 6px; +} +.wwn-badge { + background: var(--brand); + color: #0b0e14; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 7px; + border-radius: 999px; +} +.wwn-title { + font-size: 14px; + font-weight: 700; + color: var(--text); + flex: 1 1 auto; +} +.wwn-dismiss { + background: none; + border: none; + color: var(--muted); + font-size: 14px; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + line-height: 1; +} +.wwn-dismiss:hover { color: var(--text); background: rgba(255, 255, 255, 0.05); } +.wwn-list { + margin: 4px 0 0; + padding: 0 0 0 20px; + font-size: 12.5px; + line-height: 1.55; + color: #cbd2dc; +} +.wwn-list li { margin-bottom: 2px; } +.wwn-list li:last-child { margin-bottom: 0; } + +/* ═══ Welcome panel: rotating tip carousel ═══ */ +.welcome-tip-carousel { + position: relative; + width: 100%; + max-width: 720px; + display: grid; + grid-template-columns: 32px 1fr 32px; + align-items: center; + gap: 6px; + padding: 4px 0 26px; +} +.wtc-arrow { + background: rgba(255, 255, 255, 0.04); + border: 1px solid #2a3040; + color: var(--muted); + border-radius: 999px; + width: 32px; + height: 32px; + font-size: 14px; + line-height: 1; + cursor: pointer; + transition: color .15s, border-color .15s, background .15s; + align-self: center; +} +.wtc-arrow:hover { + color: var(--brand); + border-color: var(--brand); + background: rgba(74, 163, 255, 0.08); +} +.wtc-viewport { + min-height: 80px; + overflow: hidden; +} +.wtc-card { + background: #0e1117; + border: 1px solid #1d2230; + border-radius: 10px; + padding: 12px 16px; + text-align: left; + transition: opacity .18s ease, transform .18s ease; +} +.wtc-card.wtc-out { opacity: 0; transform: translateY(4px); } +.wtc-card.wtc-in { opacity: 1; transform: translateY(0); } +.wtc-head { + display: flex; + align-items: center; + gap: 8px; + margin-bottom: 4px; +} +.wtc-icon { font-size: 16px; line-height: 1; } +.wtc-title { + font-size: 13px; + font-weight: 700; + color: var(--text); + flex: 1 1 auto; +} +.wtc-badge { + background: var(--brand); + color: #0b0e14; + font-size: 10px; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0.5px; + padding: 2px 7px; + border-radius: 999px; +} +.wtc-badge.hidden { display: none; } +.wtc-body { + font-size: 12.5px; + color: #cbd2dc; + line-height: 1.55; +} +.wtc-action { + margin-top: 8px; +} +.wtc-action.hidden { display: none; } +.wtc-link { + background: none; + border: none; + color: var(--brand); + cursor: pointer; + font-size: 12px; + font-weight: 600; + padding: 0; + text-decoration: underline; +} +.wtc-link:hover { color: #7bbfff; } +.wtc-dots { + position: absolute; + bottom: 4px; + left: 50%; + transform: translateX(-50%); + display: flex; + gap: 6px; +} +.wtc-dot { + width: 6px; + height: 6px; + border-radius: 50%; + background: #333; + border: none; + padding: 0; + cursor: pointer; + transition: background .15s; +} +.wtc-dot:hover { background: #555; } +.wtc-dot.active { background: var(--brand); } diff --git a/analyzer/culling.html b/analyzer/culling.html index 6814c6fb..e53e01be 100644 --- a/analyzer/culling.html +++ b/analyzer/culling.html @@ -1246,6 +1246,12 @@

// Culling Assistant — Project Kestrel // ==================================================================== + // ── Debug-gated console logging ───────────────────────────────────────── + // Diagnostic console.log lines route through kdebug() — silent by default. + // Enable for a session via DevTools: window.__KESTREL_DEBUG = true; reload. + const _KESTREL_DEBUG = !!window.__KESTREL_DEBUG; + function kdebug(...args) { if (_KESTREL_DEBUG) console.log(...args); } + // ---- Helpers ---- function el(sel) { return document.querySelector(sel); } function escapeHtml(s) { return String(s).replace(/[&<>"']/g, c => ({'&':'&','<':'<','>':'>','"':'"',"'":'''}[c])); } @@ -2669,10 +2675,10 @@

// ---- Load data ---- async function loadData() { - console.log('[culling] loadData() starting...'); - console.log('[culling] window.pywebview:', window.pywebview); - console.log('[culling] window.pywebview.api:', window.pywebview?.api); - console.log('[culling] typeof read_kestrel_csv:', typeof window.pywebview?.api?.read_kestrel_csv); + kdebug('[culling] loadData() starting...'); + kdebug('[culling] window.pywebview:', window.pywebview); + kdebug('[culling] window.pywebview.api:', window.pywebview?.api); + kdebug('[culling] typeof read_kestrel_csv:', typeof window.pywebview?.api?.read_kestrel_csv); if (typeof window.pywebview?.api?.read_kestrel_csv !== 'function') { const ready = await waitForPywebview(['read_kestrel_csv'], 8000); diff --git a/analyzer/editor_launch.py b/analyzer/editor_launch.py index 597fcd3e..1878b562 100644 --- a/analyzer/editor_launch.py +++ b/analyzer/editor_launch.py @@ -9,7 +9,7 @@ import subprocess import sys -from settings_utils import load_persisted_settings, log +from settings_utils import load_persisted_settings, debug, info, warn, log # Cache for discovered darktable executable on Windows _DARKTABLE_EXE = None @@ -46,24 +46,24 @@ def _validate_custom_editor_path(raw: str) -> str | None: for ch in candidate: o = ord(ch) if o < 0x20 or o == 0x7F: - log('[security] customEditorPath contains control characters; rejecting.') + warn('[security] customEditorPath contains control characters; rejecting.') return None # UNC paths — drive-by NTLM relay risk. if sys.platform.startswith('win'): if candidate.startswith('\\\\') or candidate.startswith('//'): - log(f'[security] customEditorPath rejected (UNC not allowed): {candidate!r}') + warn(f'[security] customEditorPath rejected (UNC not allowed): {candidate!r}') return None expanded = os.path.expanduser(candidate) if not os.path.isabs(expanded): - log(f'[security] customEditorPath rejected (must be absolute): {candidate!r}') + warn(f'[security] customEditorPath rejected (must be absolute): {candidate!r}') return None try: resolved = os.path.realpath(expanded) except (OSError, ValueError): - log(f'[security] customEditorPath could not be resolved: {candidate!r}') + warn(f'[security] customEditorPath could not be resolved: {candidate!r}') return None if not os.path.exists(resolved): - log(f'[security] customEditorPath does not exist: {resolved!r}') + warn(f'[security] customEditorPath does not exist: {resolved!r}') return None if os.path.isdir(resolved): # macOS ``.app`` bundles are directories and are valid targets for @@ -71,10 +71,10 @@ def _validate_custom_editor_path(raw: str) -> str | None: # a misconfiguration or an attempt to hand a bogus value to Popen. if sys.platform == 'darwin' and resolved.endswith('.app'): return resolved - log(f'[security] customEditorPath rejected (is a directory): {resolved!r}') + warn(f'[security] customEditorPath rejected (is a directory): {resolved!r}') return None if not os.path.isfile(resolved): - log(f'[security] customEditorPath is neither file nor .app bundle: {resolved!r}') + warn(f'[security] customEditorPath is neither file nor .app bundle: {resolved!r}') return None return resolved @@ -111,9 +111,9 @@ def _find_darktable_exe() -> str: def launch(path: str, editor: str): path = os.path.abspath(path) - print(f"[LAUNCH] requested path={path!r} editor={editor!r} platform={sys.platform}", flush=True) + debug(f"[LAUNCH] requested path={path!r} editor={editor!r} platform={sys.platform}") if not os.path.exists(path): - print(f"[LAUNCH] ERROR: path does not exist: {path}", flush=True) + warn(f"[LAUNCH] path does not exist: {path}") raise FileNotFoundError(path) # Custom editor: load path from settings and validate before exec. @@ -127,17 +127,17 @@ def launch(path: str, editor: str): if custom_exe: # Audit log every custom-editor launch so the trail is obvious if # something goes sideways. - log(f'[editor] launching custom editor: exe={custom_exe!r} target={path!r}') + info(f'[editor] launching custom editor: exe={custom_exe!r} target={path!r}') try: if sys.platform == 'darwin' and custom_exe.endswith('.app'): subprocess.Popen(['open', '-a', custom_exe, path]); return else: subprocess.Popen([custom_exe, path]); return except Exception as e: - log(f'Custom editor launch failed ({custom_exe}): {e}, falling back to system default') + warn(f'Custom editor launch failed ({custom_exe}): {e}, falling back to system default') else: if raw_custom: - log(f'[security] customEditorPath failed validation; falling back to system default.') + warn(f'[security] customEditorPath failed validation; falling back to system default.') editor = 'system' # Editor name -> (Windows exe candidates, macOS app name, Linux commands) @@ -295,7 +295,7 @@ def launch(path: str, editor: str): subprocess.Popen([exe, path]); return except Exception: continue - log(f'{editor} not found on Windows, falling back to system default') + warn(f'{editor} not found on Windows, falling back to system default') os.startfile(path) # type: ignore[attr-defined] return @@ -309,24 +309,24 @@ def launch(path: str, editor: str): for app_name in apps_to_try: try: cmd = ['open', '-a', app_name, path] - print(f"[LAUNCH] macOS: running: {cmd}", flush=True) + debug(f"[LAUNCH] macOS: running: {cmd}") subprocess.Popen(cmd) return except Exception as e: - print(f"[LAUNCH] macOS {app_name} launch failed: {e}", flush=True) + debug(f"[LAUNCH] macOS {app_name} launch failed: {e}") if not apps_to_try: - log(f'{editor} not available on macOS, falling back to system default') + warn(f'{editor} not available on macOS, falling back to system default') # System default: try a couple of strategies and log results try: cmd = ['open', path] - print(f"[LAUNCH] macOS: trying system open: {cmd}", flush=True) + debug(f"[LAUNCH] macOS: trying system open: {cmd}") p = subprocess.run(cmd, check=False) - print(f"[LAUNCH] macOS: open returned code {p.returncode}", flush=True) + debug(f"[LAUNCH] macOS: open returned code {p.returncode}") if p.returncode == 0: return except Exception as e: - print(f"[LAUNCH] macOS: open() raised: {e}", flush=True) + debug(f"[LAUNCH] macOS: open() raised: {e}") # NOTE: the previous ``osascript -e 'tell ... open (POSIX file "")'`` # fallback has been removed. Interpolating ``path`` into an AppleScript @@ -340,11 +340,11 @@ def launch(path: str, editor: str): # Last resort: reveal in Finder try: cmd = ['open', '-R', path] - print(f"[LAUNCH] macOS: fallback reveal: {cmd}", flush=True) + debug(f"[LAUNCH] macOS: fallback reveal: {cmd}") subprocess.Popen(cmd) return except Exception as e: - print(f"[LAUNCH] macOS: reveal fallback failed: {e}", flush=True) + debug(f"[LAUNCH] macOS: reveal fallback failed: {e}") return # Linux / other diff --git a/analyzer/folder_inspector.py b/analyzer/folder_inspector.py index bf4033e3..22af260b 100644 --- a/analyzer/folder_inspector.py +++ b/analyzer/folder_inspector.py @@ -13,15 +13,8 @@ except Exception: pd = None -try: - from kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS, KESTREL_DIR_NAME, DATABASE_NAME - from kestrel_analyzer.database import load_database -except Exception: - # If kestrel_analyzer isn't available, fall back to reasonable defaults. - RAW_EXTENSIONS = ['.cr2', '.cr3', '.nef', '.arw', '.dng', '.orf', '.raf', '.rw2', '.pef', '.sr2', '.x3f'] - JPEG_EXTENSIONS = ['.jpg', '.jpeg', '.png'] - KESTREL_DIR_NAME = '.kestrel' - DATABASE_NAME = 'kestrel_database.csv' +from kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS, KESTREL_DIR_NAME, DATABASE_NAME +from kestrel_analyzer.database import load_database def _list_images_in_folder(folder: str) -> list: @@ -44,9 +37,20 @@ def _list_images_in_folder(folder: str) -> list: def inspect_folder(path: str) -> Dict[str, int | str | bool]: """Return a small summary about a folder. - Returns keys: 'root' (abs path), 'has_kestrel' (bool), 'total' (int), 'processed' (int), 'db_path' (str) + Returns keys: 'root' (abs path), 'has_kestrel' (bool), 'total' (int), + 'processed' (int), 'errored' (int — rows where species == 'Error'), + 'db_path' (str). 'processed' counts every CSV row (including errored ones) + so existing analyzed-full/partial UI math is unchanged; 'errored' is the + new field for the errored-folder UX. """ - result = {'root': '', 'has_kestrel': False, 'total': 0, 'processed': 0, 'db_path': ''} + result = { + 'root': '', + 'has_kestrel': False, + 'total': 0, + 'processed': 0, + 'errored': 0, + 'db_path': '', + } if not path: return result p = path.strip() @@ -65,6 +69,7 @@ def inspect_folder(path: str) -> Dict[str, int | str | bool]: files = _list_images_in_folder(root) total = len(files) result['total'] = total + files_set = set(files) kestrel_dir = os.path.join(root, KESTREL_DIR_NAME) db_path = os.path.join(kestrel_dir, DATABASE_NAME) @@ -72,34 +77,52 @@ def inspect_folder(path: str) -> Dict[str, int | str | bool]: if os.path.isfile(db_path): result['has_kestrel'] = True try: - # Fast-path: use pandas to read only the filename column if available processed = 0 + errored = 0 if pd is not None: try: - df = pd.read_csv(db_path, usecols=['filename']) - processed_set = set(df['filename'].astype(str).values) - processed = sum(1 for f in files if f in processed_set) + df = pd.read_csv(db_path, usecols=['filename', 'species']) + df['filename'] = df['filename'].astype(str) + df = df[df['filename'].isin(files_set)] + processed = int(len(df)) + if 'species' in df.columns: + errored = int((df['species'].astype(str) == 'Error').sum()) except Exception: - # Fall back to load_database if available + # Fall back to filename-only read or load_database try: - db, _ = load_database(kestrel_dir, analyzer_name='visualizer-inspector') - if not db.empty and 'filename' in db.columns: - processed_set = set(db['filename'].values) - processed = sum(1 for f in files if f in processed_set) + df = pd.read_csv(db_path, usecols=['filename']) + processed_set = set(df['filename'].astype(str).values) + processed = sum(1 for f in files if f in processed_set) except Exception: - processed = 0 + try: + db, _ = load_database(kestrel_dir, analyzer_name='visualizer-inspector') + if not db.empty and 'filename' in db.columns: + processed_set = set(db['filename'].values) + processed = sum(1 for f in files if f in processed_set) + if 'species' in db.columns: + errored = int( + ((db['filename'].isin(files_set)) & (db['species'].astype(str) == 'Error')).sum() + ) + except Exception: + processed = 0 else: try: db, _ = load_database(kestrel_dir, analyzer_name='visualizer-inspector') if not db.empty and 'filename' in db.columns: processed_set = set(db['filename'].values) processed = sum(1 for f in files if f in processed_set) + if 'species' in db.columns: + errored = int( + ((db['filename'].isin(files_set)) & (db['species'].astype(str) == 'Error')).sum() + ) except Exception: processed = 0 result['processed'] = int(processed) + result['errored'] = int(errored) except Exception: # Fail silently; the visualizer should still work without DB details result['processed'] = 0 + result['errored'] = 0 return result diff --git a/analyzer/kestrel_analyzer/__init__.py b/analyzer/kestrel_analyzer/__init__.py index aaf60690..d68a71cb 100644 --- a/analyzer/kestrel_analyzer/__init__.py +++ b/analyzer/kestrel_analyzer/__init__.py @@ -1,4 +1,3 @@ -#from .pipeline import AnalysisPipeline from .config import VERSION -__all__ = ["AnalysisPipeline", "VERSION"] +__all__ = ["VERSION"] diff --git a/analyzer/kestrel_analyzer/config.py b/analyzer/kestrel_analyzer/config.py index da8eb103..1529f866 100644 --- a/analyzer/kestrel_analyzer/config.py +++ b/analyzer/kestrel_analyzer/config.py @@ -1,38 +1,32 @@ from pathlib import Path -VERSION = "2.0.1" +VERSION = "2.0.4" ANALYZER_DIR = Path(__file__).resolve().parents[1] REPO_ROOT = ANALYZER_DIR.parent -DOCUMENTATION_DIR = REPO_ROOT / "documentation" -MODEL_CANDIDATE_DIR = DOCUMENTATION_DIR / "model_candidates" -MODEL_CANDIDATE_WEIGHTS_DIR = MODEL_CANDIDATE_DIR / "weights" MODELS_DIR = ANALYZER_DIR / "models" SPECIESCLASSIFIER_PATH = MODELS_DIR / "model.onnx" SPECIESCLASSIFIER_LABELS = MODELS_DIR / "labels.txt" QUALITYCLASSIFIER_PATH = MODELS_DIR / "quality.onnx" QUALITY_NORMALIZATION_DATA_PATH = MODELS_DIR / "quality_normalization_data.csv" -MASK_RCNN_WEIGHTS_PATH = MODELS_DIR / "mask_rcnn_resnet50_fpn_v2.pth" -# SAM-HQ: ViT-Tiny default (faster). For ViT-B quality, set path to sam_hq_vit_b.pth and SAM_HQ_MODEL_KEY = "vit_b". -SAM_HQ_WEIGHTS_PATH = MODELS_DIR / "sam_hq_vit_tiny.pth" -SAM_HQ_MODEL_KEY = "vit_tiny" # segment_anything_hq.sam_model_registry # SpeciesNet: bundled Kaggle-style folder (info.json + .pt + taxonomy). Passed as local model_name to speciesnet.ModelInfo. SPECIESNET_MODEL_DIR = MODELS_DIR / "speciesnet" -# Runtime-selectable MegaDetector ONNX variants (all require .onnx.data sidecar files). -# mdv5a (accurate) and mdv6-e (YOLOv9-E, fast) are bundled under models/speciesnet. -# mdv5a provides best accuracy for wildlife detection; mdv6-e is faster but less accurate. +# Runtime-selectable MegaDetector ONNX variants. +# mdv5a (accurate, YOLOv5x6 @ 1280) and mdv1000-cedar (fast, YOLOv9 gelan-c @ 640) +# are bundled under models/speciesnet. mdv5a uses a `.onnx` + `.onnx.data` sidecar +# pair; mdv1000-cedar is a single-file ONNX (no sidecar) because it was exported +# via torch.onnx.export(dynamo=False), which embeds weights inline. The legacy +# exporter path is also what makes cedar DirectML-compatible — the dynamo-exported +# mdv1000 variants hit a Reshape op DML rejects. DEFAULT_DETECTOR_NAME = "mdv5a" DETECTOR_ONNX_PATHS = { - "mdv5a": SPECIESNET_MODEL_DIR / "mdv5a.onnx", - "mdv6-e": SPECIESNET_MODEL_DIR / "mdv6-mit-yolov9-e.onnx", + "mdv5a": SPECIESNET_MODEL_DIR / "mdv5a.onnx", + "mdv1000-cedar": SPECIESNET_MODEL_DIR / "mdv1000-cedar.onnx", } -# Backward-compatible alias used by existing call sites. -MDV6_ONNX_PATH = DETECTOR_ONNX_PATHS[DEFAULT_DETECTOR_NAME] - # SAM-HQ ViT-Tiny: split encoder + decoder ONNX files. SAM_ENC_ONNX_PATH = SPECIESNET_MODEL_DIR / "sam_hq_vit_tiny_encoder.onnx" SAM_DEC_ONNX_PATH = SPECIESNET_MODEL_DIR / "sam_hq_vit_tiny_decoder.onnx" @@ -41,7 +35,26 @@ "cat", "dog", "horse", "sheep", "cow", "elephant", "bear", "zebra", "giraffe", "bird" ] -RAW_EXTENSIONS = [".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2", ".pef", ".sr2"] +# Canonical RAW format list. Single source of truth — all other modules import +# from here rather than maintaining their own copies. Adding a format here +# automatically enables it for pipeline discovery, folder inspection, RAW +# preview routing, and editor-launch allowlisting. +# +# Caveat: .raf (Fujifilm) and .x3f (Sigma) decode via rawpy but lack capture-time +# extraction in raw_exif.py. Scene grouping for these formats falls back to +# AKAZE feature similarity (no timestamp shortcut). See raw_exif.UNSUPPORTED_EXTENSIONS. +RAW_EXTENSIONS = [ + ".cr2", ".cr3", # Canon + ".nef", # Nikon + ".arw", ".srw", # Sony / Samsung NX + ".dng", # Adobe / generic + ".orf", # Olympus + ".rw2", # Panasonic + ".pef", # Pentax + ".sr2", # Sony (older) + ".raf", # Fujifilm + ".x3f", # Sigma +] JPEG_EXTENSIONS = [".jpg", ".jpeg", ".png", '.tiff', '.tif'] DATABASE_NAME = "kestrel_database.csv" diff --git a/analyzer/kestrel_analyzer/database.py b/analyzer/kestrel_analyzer/database.py index 2ae69bf4..acfbc5ec 100644 --- a/analyzer/kestrel_analyzer/database.py +++ b/analyzer/kestrel_analyzer/database.py @@ -7,6 +7,20 @@ from .config import DATABASE_NAME, METADATA_FILENAME, SCENEDATA_FILENAME, VERSION from .logging_utils import log_warning +# Leveled console logging — kestrel_analyzer is sometimes imported standalone +# (e.g. tests), so fall back to a no-op if settings_utils isn't reachable. +try: + from ..settings_utils import info as _info, warn as _warn +except (ImportError, ValueError): + # cli.py imports kestrel_analyzer as a top-level package; relative ``..`` + # walks above the root. Bare ``settings_utils`` works because analyzer/ + # is on sys.path in that case. + try: + from settings_utils import info as _info, warn as _warn # type: ignore + except ImportError: + def _info(*_a, **_kw): pass + def _warn(*_a, **_kw): pass + # Columns written by the analysis pipeline only (no user-editable data). BASE_COLUMNS = [ "filename", @@ -83,7 +97,7 @@ def load_database(kestrel_dir: str, analyzer_name: str, log_path: str = None): context={"metadata_path": metadata_path}, ) else: - print(f"Warning: failed to write metadata file: {e}") + _warn(f"[database] failed to write metadata file: {e}") database = ensure_columns(database) return database, db_path @@ -104,7 +118,7 @@ def _perform_db_upgrade( try: scenedata = _build_scenedata_from_legacy_db(database) save_scenedata(scenedata, kestrel_dir) - print(f"[database] Migrated legacy user data to {SCENEDATA_FILENAME}", flush=True) + _info(f"[database] Migrated legacy user data to {SCENEDATA_FILENAME}") except Exception as e: if log_path: log_warning( @@ -115,7 +129,7 @@ def _perform_db_upgrade( context={"kestrel_dir": kestrel_dir}, ) else: - print(f"Warning: failed to migrate legacy database: {e}", flush=True) + _warn(f"[database] failed to migrate legacy database: {e}") # Rename old CSV as backup, then save new one without legacy columns try: @@ -127,10 +141,9 @@ def _perform_db_upgrade( errors="ignore", ) cleaned.to_csv(db_path, index=False) - print( + _info( f"[database] Upgrade complete: backup at {os.path.basename(old_path)}, " - f"new clean {DATABASE_NAME} saved.", - flush=True, + f"new clean {DATABASE_NAME} saved." ) except Exception as e: if log_path: @@ -142,7 +155,7 @@ def _perform_db_upgrade( context={"kestrel_dir": kestrel_dir}, ) else: - print(f"Warning: failed to save upgraded database: {e}", flush=True) + _warn(f"[database] failed to save upgraded database: {e}") return database.drop( columns=[c for c in LEGACY_USER_COLUMNS if c in database.columns], @@ -302,7 +315,7 @@ def load_scenedata(kestrel_dir: str) -> dict: data.setdefault("scenes", {}) return data except Exception as e: - print(f"Warning: failed to load {SCENEDATA_FILENAME}: {e}", flush=True) + _warn(f"[database] failed to load {SCENEDATA_FILENAME}: {e}") return {"version": SCENEDATA_VERSION, "image_ratings": {}, "scenes": {}} diff --git a/analyzer/kestrel_analyzer/image_utils.py b/analyzer/kestrel_analyzer/image_utils.py index 287f48fd..f812b606 100644 --- a/analyzer/kestrel_analyzer/image_utils.py +++ b/analyzer/kestrel_analyzer/image_utils.py @@ -3,6 +3,10 @@ import rawpy from PIL import Image +from .config import RAW_EXTENSIONS + +_RAW_EXTENSION_SET = {ext.lower() for ext in RAW_EXTENSIONS} + def read_image(path: str): """ @@ -10,13 +14,9 @@ def read_image(path: str): Returns a numpy array in RGB format (H, W, 3) or None on failure. """ try: - # Determine file type by extension ext = os.path.splitext(path)[1].lower() - - # RAW formats supported by rawpy - raw_extensions = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.srw'} - - if ext in raw_extensions: + + if ext in _RAW_EXTENSION_SET: # Use rawpy for RAW files with rawpy.imread(path) as raw: # postprocess() applies demosaicing, white balance, color correction, etc. @@ -26,19 +26,19 @@ def read_image(path: str): else: # Use PIL for standard image formats (JPEG, PNG, TIFF, etc.) img = Image.open(path) - + # Handle EXIF orientation from PIL import ImageOps img = ImageOps.exif_transpose(img) - + # Convert to RGB (handles grayscale, RGBA, etc.) if img.mode != 'RGB': img = img.convert('RGB') - + # Convert to numpy array rgb = np.array(img) return rgb - + except rawpy.LibRawFileUnsupportedError: return None except rawpy.LibRawIOError: @@ -61,9 +61,8 @@ def read_image_for_pipeline(path: str): """ try: ext = os.path.splitext(path)[1].lower() - raw_extensions = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.raf', '.orf', '.rw2', '.srw'} - if ext in raw_extensions: + if ext in _RAW_EXTENSION_SET: # Do NOT use a context manager — we intentionally keep the object open. # Do NOT call raw.postprocess() here — the pipeline immediately calls # build_metered_detection_image() which does its own decode. The diff --git a/analyzer/kestrel_analyzer/logging_utils.py b/analyzer/kestrel_analyzer/logging_utils.py index d3cdb3ad..220d2445 100644 --- a/analyzer/kestrel_analyzer/logging_utils.py +++ b/analyzer/kestrel_analyzer/logging_utils.py @@ -7,6 +7,26 @@ from .config import KESTREL_DIR_NAME, LOG_FILENAME_PREFIX, LOG_FILE_EXTENSION +# Re-export the leveled console logger from ``settings_utils`` so any module +# inside the kestrel_analyzer subpackage (including ml/) can do +# ``from ..logging_utils import debug, info, warn, error`` without dealing +# with the package-depth or fallback-import dance themselves. The structured +# JSON channel (``log_event`` / ``log_warning`` / ``log_exception`` below) is +# unrelated and lives alongside. +try: + from ..settings_utils import debug, info, warn, error # type: ignore # noqa: F401 +except (ImportError, ValueError): + # cli.py imports kestrel_analyzer as a top-level package, so the relative + # ``..settings_utils`` walks above the package root and raises. The bare + # import works because ``analyzer/`` is on sys.path in that case. + try: + from settings_utils import debug, info, warn, error # type: ignore # noqa: F401 + except ImportError: + def debug(*_a, **_kw): pass # type: ignore[no-redef] + def info(*_a, **_kw): pass # type: ignore[no-redef] + def warn(*_a, **_kw): pass # type: ignore[no-redef] + def error(*_a, **_kw): pass # type: ignore[no-redef] + def _utc_timestamp() -> str: return datetime.utcnow().isoformat() + "Z" diff --git a/analyzer/kestrel_analyzer/ml/bird_species.py b/analyzer/kestrel_analyzer/ml/bird_species.py index 71008d39..07d938ef 100644 --- a/analyzer/kestrel_analyzer/ml/bird_species.py +++ b/analyzer/kestrel_analyzer/ml/bird_species.py @@ -4,35 +4,34 @@ import pandas as pd from ..config import MODELS_DIR -from . import gpu_providers +from ..logging_utils import debug, error +from .provider_coordinator import ProviderCoordinator +from .resilient_session import ResilientOnnxSession class BirdSpeciesClassifier: - def __init__(self, model_path: str, labels_path: str, use_gpu: bool, models_dir: str | None = None): - try: - import onnxruntime as ort - except ImportError as e: - raise RuntimeError( - f"Failed to import onnxruntime: {e}\n" - "Try reinstalling: pip uninstall onnxruntime; pip install onnxruntime" - ) from e + def __init__( + self, + model_path: str, + labels_path: str, + coord: ProviderCoordinator, + models_dir: str | None = None, + ): with open(labels_path, "r") as f: self.labels = np.array([l.strip() for l in f.readlines()]) - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - try: - self.session = ort.InferenceSession(model_path, providers=providers) - except Exception as e: - print(f"Warning: Failed to load ONNX model with specified providers: {e}") - self.session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) + # Registered with the coordinator so a DML/CoreML failure that demotes + # the wrapper's sessions also rebuilds this one — otherwise every + # subsequent image dies at the species step on a still-broken provider. + self.session = ResilientOnnxSession("bird_species", model_path, coord) active = self.session.get_providers() - print(f"[BirdSpeciesClassifier] Active provider: {active[0] if active else 'unknown'} all providers: {active}") + debug(f"[BirdSpeciesClassifier] Active provider: {active[0] if active else 'unknown'} all providers: {active}") try: base_dir = Path(models_dir) if models_dir else MODELS_DIR df_sf = pd.read_csv(base_dir / "labels_scispecies.csv") df_disp = pd.read_csv(base_dir / "scispecies_dispname.csv") except Exception as e: - print(f"Failed to load family mapping CSVs: {e}") + error(f"[BirdSpeciesClassifier] Failed to load family mapping CSVs: {e}") self.family_matrix = np.zeros((0, len(self.labels)), dtype=np.float32) self.family_display_names = [] return diff --git a/analyzer/kestrel_analyzer/ml/provider_coordinator.py b/analyzer/kestrel_analyzer/ml/provider_coordinator.py new file mode 100644 index 00000000..d6b92327 --- /dev/null +++ b/analyzer/kestrel_analyzer/ml/provider_coordinator.py @@ -0,0 +1,307 @@ +"""ONNX execution-provider coordinator: GPU/CPU state machine + auto-promote/demote. + +Owns provider state for one analysis run. The ``SpeciesNetSAMHQWrapper`` consults +this object to decide which providers a fresh ``InferenceSession`` should request, +and to react to inference failures by recreating sessions on a different provider. + +Failure modes handled: +- DML "GPU device instance has been suspended" (Windows OOM / driver reset). +- CoreML cold-start "model_path must not be empty" (macOS first-run path loss). +- DXGI device removed / hung / reset. + +Policy (tuned for typical 1000-image folders): +- Start in ``GPU`` if user enabled GPU, else ``CPU`` (and never promote). +- A "session-dead" exception in ``GPU`` demotes to ``CPU`` and asks the wrapper to + recreate every loaded session. +- After ``initial_promote_threshold`` consecutive successful CPU images, attempt + one promotion back to ``GPU``. If that promotion (or the next inference) fails, + demote again and double the threshold (capped). After + ``max_demotions_before_lockout`` demote events, transition to ``GPU_LOCKED_OUT`` + and stay on CPU for the rest of the run. + +This module is single-thread by design — the analysis pipeline processes images +sequentially. If parallel-image processing is ever introduced, add a lock around +``on_run_success`` / ``on_run_failure`` / ``attempt_promotion``. +""" + +from __future__ import annotations + +import gc +from dataclasses import dataclass +from enum import Enum +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional + +from . import gpu_providers + +if TYPE_CHECKING: + from .resilient_session import ResilientOnnxSession + + +class ProviderState(Enum): + GPU = "gpu" + CPU = "cpu" + GPU_LOCKED_OUT = "gpu_locked_out" + + +class FailureAction(Enum): + RECREATE_AND_RETRY = "recreate_and_retry" + PROPAGATE = "propagate" + + +@dataclass(frozen=True) +class ResilienceConfig: + initial_promote_threshold: int = 25 + max_promote_threshold: int = 400 + promote_backoff_factor: float = 2.0 + max_demotions_before_lockout: int = 3 + max_attempts_per_image: int = 2 + aggressive_recreate: bool = False + + @classmethod + def from_settings(cls, settings: dict | None) -> "ResilienceConfig": + s = settings or {} + if not bool(s.get("gpu_resilience_enabled", True)): + return cls( + initial_promote_threshold=cls.initial_promote_threshold, + max_promote_threshold=cls.max_promote_threshold, + promote_backoff_factor=cls.promote_backoff_factor, + max_demotions_before_lockout=0, + max_attempts_per_image=1, + aggressive_recreate=False, + ) + return cls(aggressive_recreate=bool(s.get("gpu_aggressive_recreate", False))) + + +# Substring matches against str(exc) for known "session is now corpse" errors. +_SESSION_DEAD_SIGNATURES: tuple[str, ...] = ( + "GPU device instance has been suspended", + "887A0005", + "DXGI_ERROR_DEVICE_REMOVED", + "DXGI_ERROR_DEVICE_HUNG", + "DXGI_ERROR_DEVICE_RESET", + "GetDeviceRemovedReason", + "!model_path.empty()", + "model_path must not be empty", +) + + +def is_session_dead(exc: BaseException, *, aggressive: bool = False) -> bool: + """Return True if ``exc`` indicates the ONNX session needs to be recreated. + + Conservative by default: only known broken-session signatures match. With + ``aggressive=True``, any ``onnxruntime`` Fail/RuntimeException matches — + useful as a triage flag when a new failure mode appears in the wild. + """ + msg = str(exc) if exc is not None else "" + type_name = type(exc).__name__ if exc is not None else "" + + if any(sig in msg for sig in _SESSION_DEAD_SIGNATURES): + return True + if "CoreML" in msg and "fail" in msg.lower(): + return True + + if aggressive: + # Match onnxruntime.capi.onnxruntime_pybind11_state.{Fail,RuntimeException,...} + mod = type(exc).__module__ if exc is not None else "" + if "onnxruntime" in mod or type_name in {"Fail", "RuntimeException"}: + return True + return False + + +_RecreateFn = Callable[[bool], None] +_StatusFn = Callable[[str], None] + + +class ProviderCoordinator: + """State machine + recreation orchestrator for a single analysis run. + + Wiring expectations: + - ``register_recreate_callback`` is called once by the wrapper, supplying a + function ``recreate(target_use_gpu: bool) -> None`` that tears down all + live sessions and rebuilds the previously-loaded ones on the new provider. + - The wrapper calls ``providers_for(kind)`` when constructing each session. + - The wrapper calls ``on_run_success()`` after each successful image and + ``on_run_failure(exc)`` from the per-image retry loop. + """ + + def __init__( + self, + *, + user_gpu_enabled: bool, + cfg: Optional[ResilienceConfig] = None, + status_cb: Optional[_StatusFn] = None, + ): + self._user_gpu_enabled = bool(user_gpu_enabled) + self.cfg = cfg or ResilienceConfig() + self._status_cb = status_cb + self._state = ProviderState.GPU if self._user_gpu_enabled else ProviderState.CPU + self._cpu_success_streak = 0 + self._current_promote_threshold = self.cfg.initial_promote_threshold + self._demotions = 0 + self._recreate_cb: Optional[_RecreateFn] = None + # All ResilientOnnxSession instances built against this coord. The + # coordinator walks this list to recreate sessions on demote/promote, + # so that ALL pipeline sessions (wrapper detector/classifier/SAM AND + # the separately-owned BirdSpeciesClassifier / QualityClassifier) move + # together. If only some sessions migrate, the dead-provider sessions + # error every subsequent image and the run looks "stuck on error". + self._sessions: list["ResilientOnnxSession"] = [] + + # ---- public read-only ---- + + @property + def effective_use_gpu(self) -> bool: + return self._state is ProviderState.GPU + + @property + def state(self) -> ProviderState: + return self._state + + @property + def cpu_success_streak(self) -> int: + return self._cpu_success_streak + + @property + def current_promote_threshold(self) -> int: + return self._current_promote_threshold + + # ---- wiring ---- + + def register_recreate_callback(self, fn: _RecreateFn) -> None: + self._recreate_cb = fn + + def update_status_cb(self, status_cb: Optional[_StatusFn]) -> None: + """Rebind the status callback. Called when a wrapper is reused across + analysis runs so notifications target the active folder, not the one + whose ``status_cb`` closure was captured at first construction. + """ + self._status_cb = status_cb + + def _register_session(self, sess: "ResilientOnnxSession") -> None: + self._sessions.append(sess) + + def recreate_all(self) -> None: + """Rebuild every registered session against the coordinator's current + provider list. Called after a state transition (demote or promote). + Safe to call when no sessions are registered (no-op). + """ + for sess in list(self._sessions): + sess._rebuild() + gc.collect() + + def providers_for(self, kind: str) -> list[str]: + """Return the ONNX providers list for a fresh session of the given kind. + + ``kind`` is a free-form string ("detector"/"classifier"/"sam_enc"/"sam_dec"); + currently all kinds use the same provider list, but the parameter is kept + so we can per-kind tune later without changing call sites. + """ + _ = kind + if self._state is ProviderState.GPU: + return gpu_providers() + return ["CPUExecutionProvider"] + + # ---- event handlers ---- + + def on_run_success(self) -> None: + if self._state is ProviderState.GPU: + return + # CPU or LOCKED_OUT — count successes; only GPU promotion uses the streak. + self._cpu_success_streak += 1 + + def on_run_failure(self, exc: BaseException) -> FailureAction: + """Classify ``exc`` and decide whether the wrapper should recreate sessions. + + Side effect: when returning RECREATE_AND_RETRY, this method has already + transitioned to CPU and emitted a status message. The wrapper is expected + to call its recreate function (which re-asks ``providers_for`` and + therefore picks up the new state) before retrying. + """ + if not is_session_dead(exc, aggressive=self.cfg.aggressive_recreate): + return FailureAction.PROPAGATE + if self._state is not ProviderState.GPU: + # Already on CPU; recreating won't help. Let the per-image catcher + # in pipeline.py mark this image errored. + return FailureAction.PROPAGATE + self._demote(reason=str(exc)) + return FailureAction.RECREATE_AND_RETRY + + def should_try_promote(self) -> bool: + if self._state is not ProviderState.CPU: + return False + if not self._user_gpu_enabled: + return False + return self._cpu_success_streak >= self._current_promote_threshold + + def attempt_promotion(self) -> bool: + """Try to promote CPU→GPU once. Returns True on success, False on failure. + + Called by the wrapper between images when ``should_try_promote()`` is + true. On failure, the coordinator stays on CPU, doubles the threshold + (capped), and may transition to ``GPU_LOCKED_OUT`` if too many demotions + have accumulated. + """ + if self._state is not ProviderState.CPU: + return False + if self._recreate_cb is None: + return False + self._notify("Re-attempting GPU acceleration...") + # Optimistically transition before recreate, so providers_for() returns + # GPU during the rebuild. On failure we transition back inside _demote. + self._state = ProviderState.GPU + self._cpu_success_streak = 0 + try: + self._recreate_cb(True) + except Exception as e: + self._on_promotion_failure(reason=str(e)) + return False + return True + + # ---- internal ---- + + def _demote(self, *, reason: str) -> None: + self._state = ProviderState.CPU + self._cpu_success_streak = 0 + self._demotions += 1 + if self._demotions >= self.cfg.max_demotions_before_lockout > 0: + self._state = ProviderState.GPU_LOCKED_OUT + self._notify("GPU disabled for the rest of this run — repeated GPU failures.") + return + next_at = self._current_promote_threshold + self._notify( + f"Switched to CPU after GPU error — will retry GPU after {next_at} successful images." + ) + + def _on_promotion_failure(self, *, reason: str) -> None: + # Transition back to CPU and back off the threshold. + self._state = ProviderState.CPU + self._cpu_success_streak = 0 + self._demotions += 1 + new_threshold = int(self._current_promote_threshold * self.cfg.promote_backoff_factor) + self._current_promote_threshold = min(new_threshold, self.cfg.max_promote_threshold) + if self._demotions >= self.cfg.max_demotions_before_lockout > 0: + self._state = ProviderState.GPU_LOCKED_OUT + self._notify("GPU disabled for the rest of this run — repeated GPU failures.") + return + self._notify( + f"GPU re-enable failed — staying on CPU " + f"(next attempt after {self._current_promote_threshold} more images)." + ) + + def _notify(self, msg: str) -> None: + if self._status_cb is None: + return + try: + self._status_cb(msg) + except Exception: + pass + + +__all__ = [ + "FailureAction", + "ProviderCoordinator", + "ProviderState", + "ResilienceConfig", + "is_session_dead", +] diff --git a/analyzer/kestrel_analyzer/ml/quality.py b/analyzer/kestrel_analyzer/ml/quality.py index 0a3fd6ee..96e41939 100644 --- a/analyzer/kestrel_analyzer/ml/quality.py +++ b/analyzer/kestrel_analyzer/ml/quality.py @@ -3,7 +3,9 @@ import cv2 import numpy as np -from . import gpu_providers +from ..logging_utils import debug +from .provider_coordinator import ProviderCoordinator +from .resilient_session import ResilientOnnxSession class QualityClassifier: @@ -12,20 +14,14 @@ def __init__( model_path: str, normalization_data_path: str = None, *, - use_gpu: bool = True, + coord: ProviderCoordinator, ): - import onnxruntime as ort - - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - try: - self.session = ort.InferenceSession(model_path, providers=providers) - except Exception as e: - print(f"[QualityClassifier] Failed with preferred providers ({e}), falling back to CPU") - self.session = ort.InferenceSession(model_path, providers=["CPUExecutionProvider"]) - + # Registered with the coordinator so demote/promote rebuilds this + # session alongside the wrapper's. See note in BirdSpeciesClassifier. + self.session = ResilientOnnxSession("quality", model_path, coord) self.providers_used = list(self.session.get_providers()) _active = self.providers_used[0] if self.providers_used else "unknown" - print(f"[QualityClassifier] Active provider: {_active} all providers: {self.providers_used}") + debug(f"[QualityClassifier] Active provider: {_active} all providers: {self.providers_used}") self._input_name = self.session.get_inputs()[0].name diff --git a/analyzer/kestrel_analyzer/ml/resilient_session.py b/analyzer/kestrel_analyzer/ml/resilient_session.py new file mode 100644 index 00000000..de9200c8 --- /dev/null +++ b/analyzer/kestrel_analyzer/ml/resilient_session.py @@ -0,0 +1,86 @@ +"""Drop-in wrapper around ``ort.InferenceSession`` that delegates failures to a coordinator. + +The wrapper exposes the public surface of ``InferenceSession`` used in the +analyzer (``run``, ``get_providers``, ``get_inputs``, ``get_outputs``) so each +detector / classifier / SAM session-owner can hold one of these instead of a +raw session and not care about provider state. + +Path normalization mitigation for the macOS Bug A failure: +``Path(model_path).resolve()`` is applied here once, before the session is +constructed, so all five session sites get the absolute-path mitigation for +free. +""" + +from __future__ import annotations + +from pathlib import Path +from typing import TYPE_CHECKING, Any + +if TYPE_CHECKING: + from .provider_coordinator import ProviderCoordinator + + +class ResilientOnnxSession: + """Thin pass-through to ``onnxruntime.InferenceSession`` with rebuild support. + + The coordinator is the only thing that calls ``_rebuild`` — the wrapper + looks the session up via the coordinator after a recreate, so the detectors + don't need to know anything about provider state. + """ + + def __init__( + self, + kind: str, + model_path: Path | str, + coord: "ProviderCoordinator", + ) -> None: + # Normalize to an absolute path before handing it to ONNX Runtime. This + # is the macOS Bug A mitigation — CoreML's graph-optimization path can + # lose the model directory when it receives a relative path, leading to + # ``Initializer ... model_path must not be empty``. + self._kind = str(kind) + self._path = Path(model_path).resolve() + self._coord = coord + self._session: Any = None + self._build() + # Register with the coordinator so a single recreate_all() call walks + # every ONNX session in the run — wrapper sessions AND classifier + # sessions owned by pipeline.py. + coord._register_session(self) + + def _build(self) -> None: + import onnxruntime as ort # imported lazily so test environments without ORT can import this module + + providers = self._coord.providers_for(self._kind) + self._session = ort.InferenceSession(str(self._path), providers=providers) + + def _rebuild(self) -> None: + """Drop the current session and build a new one using the coordinator's + current provider list. Called by the wrapper's ``recreate_sessions`` + path. Releasing the old session first matters for DML/CoreML, which + hold device memory only as long as the C++ session object lives. + """ + self._session = None + self._build() + + # ---- pass-through API mirroring ort.InferenceSession ---- + + def run(self, output_names, input_feed, run_options=None): + try: + return self._session.run(output_names, input_feed, run_options) + except Exception: + # Re-raise unchanged. The coordinator is consulted at the wrapper / + # per-image-retry level, NOT here, so we don't double-handle. + raise + + def get_providers(self) -> list[str]: + return list(self._session.get_providers()) + + def get_inputs(self): + return self._session.get_inputs() + + def get_outputs(self): + return self._session.get_outputs() + + +__all__ = ["ResilientOnnxSession"] diff --git a/analyzer/kestrel_analyzer/ml/speciesnet_sam_hq.py b/analyzer/kestrel_analyzer/ml/speciesnet_sam_hq.py index 588979e1..afa4a433 100644 --- a/analyzer/kestrel_analyzer/ml/speciesnet_sam_hq.py +++ b/analyzer/kestrel_analyzer/ml/speciesnet_sam_hq.py @@ -4,7 +4,7 @@ import os from pathlib import Path -from typing import Any, Optional +from typing import Any, Callable, Optional import cv2 import numpy as np @@ -17,7 +17,14 @@ SAM_ENC_ONNX_PATH, SPECIESNET_MODEL_DIR, ) -from . import gpu_providers, is_gpu_active +from ..logging_utils import debug, warn +from . import is_gpu_active +from .provider_coordinator import ( + FailureAction, + ProviderCoordinator, + ResilienceConfig, +) +from .resilient_session import ResilientOnnxSession from .speciesnet_taxonomy import ( bird_vs_wildlife_classifier_scores, is_ambiguous_generic_taxonomy, @@ -44,8 +51,7 @@ _PRE_CLASSIFIER_IOU = 0.85 _PRE_CLASSIFIER_CONTAINMENT = 0.95 _SUPPORTED_DETECTOR_NAMES = tuple(DETECTOR_ONNX_PATHS.keys()) -_YOLOV9_DETECTOR_NAMES = {"mdv6-c", "mdv6-e"} -_MDV5A_DETECTOR_NAMES = {"mdv5a"} +_MDV1000_CEDAR_DETECTOR_NAMES = {"mdv1000-cedar"} def _coerce_max_bird_crops(value) -> int: @@ -270,16 +276,13 @@ class OnnxClassifier: IMG_SIZE = 480 - def __init__(self, onnx_path: Path, labels_path: Path, use_gpu: bool = False): - import onnxruntime as ort - - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - self._session = ort.InferenceSession(str(onnx_path), providers=providers) + def __init__(self, onnx_path: Path, labels_path: Path, coord: ProviderCoordinator): + self._session = ResilientOnnxSession("classifier", onnx_path, coord) self.providers_used = self._session.get_providers() with open(labels_path) as f: self._labels = [line.strip() for line in f] _active = self.providers_used[0] if self.providers_used else "unknown" - print(f"[OnnxClassifier] {len(self._labels)} labels Active provider: {_active} all providers: {self.providers_used}") + debug(f"[OnnxClassifier] {len(self._labels)} labels Active provider: {_active} all providers: {self.providers_used}") def preprocess(self, img_pil: Image.Image, bboxes: list | None = None) -> np.ndarray: """ @@ -307,6 +310,10 @@ def preprocess(self, img_pil: Image.Image, bboxes: list | None = None) -> np.nda crop_resized = crop.resize((self.IMG_SIZE, self.IMG_SIZE), Image.BILINEAR) return np.array(crop_resized, dtype=np.uint8) # (480, 480, 3) HWC uint8 + def preprocess_many(self, img_pil: Image.Image, bboxes: list) -> list[np.ndarray]: + """Batch-friendly preprocess: one uint8 480x480 crop per bbox.""" + return [self.preprocess(img_pil, bboxes=[b]) for b in bboxes] + def predict(self, filepath: str, preprocessed: np.ndarray) -> dict: """ Run ONNX inference and return classifications in SpeciesNet format. @@ -327,6 +334,36 @@ def predict(self, filepath: str, preprocessed: np.ndarray) -> dict: } } + def predict_many(self, filepaths: list[str], preprocessed_list: list[np.ndarray]) -> list[dict]: + """ + Run one ONNX forward pass for many preprocessed crops. + + Returns one SpeciesNet-format classification dict per input crop: + {"classifications": {"classes": [...], "scores": [...]} } + """ + if not preprocessed_list: + return [] + if len(filepaths) != len(preprocessed_list): + raise ValueError("filepaths and preprocessed_list lengths must match") + + inp = np.stack(preprocessed_list, axis=0).astype(np.float32) / 255.0 # (N,480,480,3) + logits_batch = self._session.run(None, {"input": inp})[0] # (N, N_classes) + + results: list[dict] = [] + for logits in logits_batch: + exp = np.exp(logits - logits.max()) + scores = exp / exp.sum() + order = np.argsort(scores)[::-1] + results.append( + { + "classifications": { + "classes": [self._labels[i] for i in order], + "scores": [float(scores[i]) for i in order], + } + } + ) + return results + class OnnxMDv5Detector: """ @@ -349,20 +386,17 @@ class OnnxMDv5Detector: _NMS_IOU = 0.5 _PRE_NMS_LIMIT = 4000 - def __init__(self, onnx_path: Path, use_gpu: bool = False) -> None: - import onnxruntime as ort - + def __init__(self, onnx_path: Path, coord: ProviderCoordinator) -> None: onnx_path = Path(onnx_path) if not onnx_path.is_file(): raise FileNotFoundError( f"MDv5a weights not found: {onnx_path}\n" "Place mdv5a.onnx (and mdv5a.onnx.data) under models/speciesnet/." ) - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - self._session = ort.InferenceSession(str(onnx_path), providers=providers) + self._session = ResilientOnnxSession("detector", onnx_path, coord) _provs = self._session.get_providers() self.device = "ONNX/GPU" if is_gpu_active(_provs) else "ONNX/CPU" - print(f"[OnnxMDv5Detector] Loaded {onnx_path.name} providers={_provs}") + debug(f"[OnnxMDv5Detector] Loaded {onnx_path.name} providers={_provs}") def preprocess(self, img_pil: "Image.Image") -> tuple: """Resize image to 1280x1280 (simple resize, not letterbox).""" @@ -496,159 +530,50 @@ def predict(self, filepath: str, det_input: tuple) -> dict: return {"filepath": filepath, "detections": detections} -class OnnxMDv6Detector: +class OnnxMDv1000CedarDetector: """ - MegaDetector v6 (RT-DETRv2-C) via ONNX Runtime. + MegaDetector v1000 ``cedar`` variant (YOLOv9 gelan-c head) via ONNX Runtime. - Interface (identical to MDv6Detector): - preprocess(img_pil) → (img_tensor, orig_w, orig_h) + Cedar's ONNX is a single-file export (no `.onnx.data` sidecar) produced via + ``torch.onnx.export(dynamo=False)``. That export path embeds weights inline + and — critically — emits Reshape ops the DirectML execution provider accepts, + so cedar runs on the GPU on Windows. + + Interface matches the other detectors: + preprocess(img_pil) → (img_tensor, scale, pad_left, pad_top, orig_w, orig_h) predict(filepath, det_input) → {"filepath": str, "detections": [{"label": str, "conf": float, "bbox": [xmin,ymin,w,h]}]} - Preprocessing squashes the image to 640×640 (CPU, pure numpy). - Category map: 0 → "animal" 1 → "person" 2 → "vehicle" - """ - - _LABEL_MAP: dict[int, str] = {0: "animal", 1: "person", 2: "vehicle"} - - def __init__(self, onnx_path: Path, use_gpu: bool = False) -> None: - import onnxruntime as ort - - onnx_path = Path(onnx_path) - if not onnx_path.is_file(): - raise FileNotFoundError( - f"MDv6 weights not found: {onnx_path}\n" - "Place mdv6-apa-rtdetr-c.onnx (and mdv6-apa-rtdetr-c.onnx.data) " - "under models/speciesnet/." - ) - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - self._session = ort.InferenceSession(str(onnx_path), providers=providers) - _provs = self._session.get_providers() - self.device = "ONNX/GPU" if is_gpu_active(_provs) else "ONNX/CPU" - _active = _provs[0] if _provs else "unknown" - print(f"[OnnxMDv6Detector] Loaded {onnx_path.name} Active provider: {_active} all providers: {_provs}") + Preprocessing letterboxes the image to 640×640 (aspect-preserving + grey-114 + pad) with [0,1] RGB float input. The ONNX output is shape ``(1, 7, 8400)`` + channels-first — 4 box channels (cx, cy, w, h in network pixel space) + 3 + sigmoid-activated class scores (animal, person, vehicle). The decoder + inverse-letterboxes boxes back to original-image space and runs per-class + greedy NMS at IoU 0.5. - def preprocess(self, img_pil: "Image.Image") -> tuple: - """CPU: squash PIL to 640×640 float tensor; record original dims. - - Returns (img_tensor [1,3,640,640] float32 [0,1], orig_w, orig_h). - """ - orig_w, orig_h = img_pil.size - img_640 = np.array(img_pil.resize((640, 640), Image.BILINEAR), dtype=np.float32) / 255.0 - img_tensor = img_640.transpose(2, 0, 1)[np.newaxis] # [1, 3, 640, 640] - return (img_tensor, orig_w, orig_h) - - def predict(self, filepath: str, det_input: tuple) -> dict: - """ONNX inference + decode absolute xyxy → normalised xywh.""" - img_tensor, orig_w, orig_h = det_input - orig_sizes = np.array([[orig_w, orig_h]], dtype=np.float32) - labels_b, boxes_b, scores_b = self._session.run( - None, {"images": img_tensor, "orig_target_sizes": orig_sizes} - ) - labels = labels_b[0] # (300,) - boxes = boxes_b[0] # (300, 4) xyxy absolute pixels in orig space - scores = scores_b[0] # (300,) - - detections: list[dict] = [] - for i in range(len(labels)): - conf = float(scores[i]) - if conf < 0.01: - continue - cls_idx = int(labels[i]) - x1, y1, x2, y2 = float(boxes[i][0]), float(boxes[i][1]), float(boxes[i][2]), float(boxes[i][3]) - bbox = [ - x1 / orig_w, - y1 / orig_h, - (x2 - x1) / orig_w, - (y2 - y1) / orig_h, - ] - label = self._LABEL_MAP.get(cls_idx, "unknown") - detections.append({"label": label, "conf": conf, "bbox": bbox}) - return {"filepath": filepath, "detections": detections} - - -class OnnxMDv6MitYoloV9Detector: - """ - MegaDetector v6 MIT YOLOv9 variants via ONNX Runtime. - - The exported ONNX graph expects two inputs: - images : [1, 3, 640, 640] float32 in [0,1] - rev_tensor: [1, 5] = [scale, pad_left, pad_top, pad_left, pad_top] - - It outputs raw class logits and decoded boxes. The graph already applies - reverse letterbox transform using ``rev_tensor``, so output boxes are in - the original image coordinate space. + Category map: 0 → "animal" 1 → "person" 2 → "vehicle" """ _LABEL_MAP: dict[int, str] = {0: "animal", 1: "person", 2: "vehicle"} _INPUT_SIZE = 640 _MIN_CONF = 0.01 _NMS_IOU = 0.5 - _MAX_BBOX_PER_CLASS = 300 _PRE_NMS_LIMIT = 4000 _PAD_COLOR = (114, 114, 114) - def __init__(self, onnx_path: Path, use_gpu: bool = False) -> None: - import onnxruntime as ort - + def __init__(self, onnx_path: Path, coord: ProviderCoordinator) -> None: onnx_path = Path(onnx_path) if not onnx_path.is_file(): raise FileNotFoundError( - f"MDv6 MIT YOLOv9 weights not found: {onnx_path}\n" - "Place mdv6-mit-yolov9-*.onnx (and .onnx.data) under models/speciesnet/." + f"mdv1000-cedar weights not found: {onnx_path}\n" + "Place mdv1000-cedar.onnx under models/speciesnet/ (single file — no .onnx.data sidecar)." ) - - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - self._session = ort.InferenceSession(str(onnx_path), providers=providers) - - inputs = self._session.get_inputs() - outputs = self._session.get_outputs() - self._images_input_name = self._pick_io_name(inputs, preferred=("images", "image", "input")) - self._rev_input_name = self._pick_io_name( - inputs, - preferred=("rev_tensor", "rev"), - exclude={self._images_input_name}, - ) - self._logits_output_name = self._pick_io_name( - outputs, - preferred=("raw_class_logits", "class", "logits"), - ) - self._boxes_output_name = self._pick_io_name( - outputs, - preferred=("raw_boxes", "boxes", "bbox"), - exclude={self._logits_output_name}, - ) - + self._session = ResilientOnnxSession("detector", onnx_path, coord) _provs = self._session.get_providers() self.device = "ONNX/GPU" if is_gpu_active(_provs) else "ONNX/CPU" - _active = _provs[0] if _provs else "unknown" - print( - f"[OnnxMDv6MitYoloV9Detector] Loaded {onnx_path.name}" - f" Active provider: {_active} all providers: {_provs}" - f" inputs=({self._images_input_name}, {self._rev_input_name})" - f" outputs=({self._logits_output_name}, {self._boxes_output_name})" - ) - - @staticmethod - def _pick_io_name( - io_nodes, - preferred: tuple[str, ...], - exclude: Optional[set[str]] = None, - ) -> str: - excluded = exclude or set() - names = [node.name for node in io_nodes if node.name not in excluded] - if not names: - raise RuntimeError("Failed to resolve ONNX input/output names.") - - lowered = [name.lower() for name in names] - for token in preferred: - token = token.lower() - for idx, lname in enumerate(lowered): - if token in lname: - return names[idx] - return names[0] + debug(f"[OnnxMDv1000CedarDetector] Loaded {onnx_path.name} providers={_provs}") @staticmethod def _nms_xyxy(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> np.ndarray: @@ -682,20 +607,18 @@ def _nms_xyxy(boxes: np.ndarray, scores: np.ndarray, iou_threshold: float) -> np return np.array(keep, dtype=np.int64) - @staticmethod - def _sigmoid(x: np.ndarray) -> np.ndarray: - x = np.clip(x, -50.0, 50.0) - return 1.0 / (1.0 + np.exp(-x)) - def preprocess(self, img_pil: "Image.Image") -> tuple: - """Pad-resize image to 640x640 and build rev_tensor expected by the ONNX graph.""" + """Letterbox the image into the fixed 640×640 input. Returns the + normalized RGB tensor plus the (scale, pad_left, pad_top) needed to + inverse-transform output boxes back to original-image space. + """ orig_w, orig_h = img_pil.size scale = min(self._INPUT_SIZE / float(orig_w), self._INPUT_SIZE / float(orig_h)) - new_w = max(1, int(orig_w * scale)) - new_h = max(1, int(orig_h * scale)) + new_w = max(1, int(round(orig_w * scale))) + new_h = max(1, int(round(orig_h * scale))) - resized = img_pil.resize((new_w, new_h), Image.Resampling.LANCZOS) + resized = img_pil.resize((new_w, new_h), Image.Resampling.BILINEAR) pad_left = (self._INPUT_SIZE - new_w) // 2 pad_top = (self._INPUT_SIZE - new_h) // 2 @@ -703,86 +626,104 @@ def preprocess(self, img_pil: "Image.Image") -> tuple: canvas.paste(resized, (pad_left, pad_top)) img_np = np.asarray(canvas, dtype=np.float32) / 255.0 - img_tensor = img_np.transpose(2, 0, 1)[np.newaxis] - rev_tensor = np.array( - [[scale, float(pad_left), float(pad_top), float(pad_left), float(pad_top)]], - dtype=np.float32, - ) - return (img_tensor, rev_tensor, orig_w, orig_h) + img_tensor = img_np.transpose(2, 0, 1)[np.newaxis] # [1, 3, 640, 640] + return (img_tensor, float(scale), int(pad_left), int(pad_top), int(orig_w), int(orig_h)) def predict(self, filepath: str, det_input: tuple) -> dict: - """ONNX inference + sigmoid + class-wise NMS, returned as normalized xywh detections.""" - img_tensor, rev_tensor, orig_w, orig_h = det_input - raw = self._session.run( - [self._logits_output_name, self._boxes_output_name], - { - self._images_input_name: img_tensor, - self._rev_input_name: rev_tensor, - }, - ) + """ONNX inference + decode (1,7,N) channels-first output → normalized xywh detections.""" + img_tensor, scale, pad_left, pad_top, orig_w, orig_h = det_input + raw = self._session.run(None, {"images": img_tensor}) if not raw: return {"filepath": filepath, "detections": []} - class_logits, boxes_b = raw - if class_logits.ndim != 3 or boxes_b.ndim != 3: - raise RuntimeError( - f"Unexpected mdv6-mit-yolov9 output shapes: logits={class_logits.shape}, boxes={boxes_b.shape}" - ) + preds = raw[0] + if preds.ndim != 3 or preds.shape[1] != 7: + raise RuntimeError(f"Unexpected mdv1000-cedar output shape: {preds.shape}") - class_probs = self._sigmoid(class_logits[0]) - boxes = boxes_b[0] - if boxes.shape[1] != 4 or class_probs.shape[0] != boxes.shape[0]: - raise RuntimeError( - f"Unexpected mdv6-mit-yolov9 tensor dimensions: probs={class_probs.shape}, boxes={boxes.shape}" - ) + pred = preds[0].T # (N, 7) — 4 box + 3 sigmoid class scores + cls_scores = pred[:, 4:7] + cls_idx = np.argmax(cls_scores, axis=1).astype(np.int64) + conf = cls_scores[np.arange(cls_scores.shape[0]), cls_idx] - detections: list[dict] = [] - n_classes = min(class_probs.shape[1], len(self._LABEL_MAP)) - for class_id in range(n_classes): - label = self._LABEL_MAP.get(class_id) - if label is None: - continue + keep = conf >= self._MIN_CONF + if not np.any(keep): + return {"filepath": filepath, "detections": []} + + pred = pred[keep] + cls_idx = cls_idx[keep] + conf = conf[keep] + + # Box coords are in network-pixel space (0..INPUT_SIZE). If the model ever + # emits normalized coords (rare), scale up. + max_coord = float(np.max(pred[:, :4])) + if max_coord <= 2.0: + cx = pred[:, 0] * self._INPUT_SIZE + cy = pred[:, 1] * self._INPUT_SIZE + bw = pred[:, 2] * self._INPUT_SIZE + bh = pred[:, 3] * self._INPUT_SIZE + else: + cx = pred[:, 0] + cy = pred[:, 1] + bw = pred[:, 2] + bh = pred[:, 3] + + # Inverse letterbox: undo the (scale, pad) transform, then normalize to original image. + x1 = ((cx - bw / 2.0) - pad_left) / scale + y1 = ((cy - bh / 2.0) - pad_top) / scale + x2 = ((cx + bw / 2.0) - pad_left) / scale + y2 = ((cy + bh / 2.0) - pad_top) / scale + x1 = np.clip(x1 / float(orig_w), 0.0, 1.0) + y1 = np.clip(y1 / float(orig_h), 0.0, 1.0) + x2 = np.clip(x2 / float(orig_w), 0.0, 1.0) + y2 = np.clip(y2 / float(orig_h), 0.0, 1.0) + boxes = np.stack([x1, y1, x2, y2], axis=1).astype(np.float32) - scores = class_probs[:, class_id] - class_indices = np.where(scores >= self._MIN_CONF)[0] - if class_indices.size == 0: + selected: list[int] = [] + for class_id in np.unique(cls_idx): + class_indices = np.where(cls_idx == class_id)[0] + class_scores = conf[class_indices] + if class_scores.size == 0: continue - if class_indices.size > self._PRE_NMS_LIMIT: - top_local = np.argsort(scores[class_indices])[-self._PRE_NMS_LIMIT:] + if class_scores.size > self._PRE_NMS_LIMIT: + top_local = np.argsort(class_scores)[-self._PRE_NMS_LIMIT:] class_indices = class_indices[top_local] + class_scores = conf[class_indices] keep_local = self._nms_xyxy( boxes[class_indices], - scores[class_indices].astype(np.float32), + class_scores.astype(np.float32), iou_threshold=self._NMS_IOU, ) - kept_indices = class_indices[keep_local] - if kept_indices.size > self._MAX_BBOX_PER_CLASS: - top_by_score = np.argsort(scores[kept_indices])[::-1][: self._MAX_BBOX_PER_CLASS] - kept_indices = kept_indices[top_by_score] - - for i in kept_indices: - x1, y1, x2, y2 = [float(v) for v in boxes[i]] - x1 = float(np.clip(x1, 0.0, float(orig_w))) - y1 = float(np.clip(y1, 0.0, float(orig_h))) - x2 = float(np.clip(x2, 0.0, float(orig_w))) - y2 = float(np.clip(y2, 0.0, float(orig_h))) - if x2 <= x1 or y2 <= y1: - continue + selected.extend(class_indices[keep_local].tolist()) - detections.append( - { - "label": label, - "conf": float(scores[i]), - "bbox": [ - x1 / float(orig_w), - y1 / float(orig_h), - (x2 - x1) / float(orig_w), - (y2 - y1) / float(orig_h), - ], - } - ) + if not selected: + return {"filepath": filepath, "detections": []} + + selected_arr = np.array(selected, dtype=np.int64) + order = np.argsort(conf[selected_arr])[::-1] + selected_arr = selected_arr[order] + + detections: list[dict] = [] + for i in selected_arr: + cls_id = int(cls_idx[i]) + label = self._LABEL_MAP.get(cls_id, "unknown") + if label == "unknown": + continue + + bx1, by1, bx2, by2 = [float(v) for v in boxes[i]] + detections.append( + { + "label": label, + "conf": float(conf[i]), + "bbox": [ + bx1, + by1, + max(0.0, bx2 - bx1), + max(0.0, by2 - by1), + ], + } + ) detections.sort(key=lambda d: float(d.get("conf", 0.0)), reverse=True) return {"filepath": filepath, "detections": detections} @@ -801,16 +742,96 @@ class OnnxSamPredictor: _IMG_SIZE = 1024 - def __init__(self, enc_path: Path, dec_path: Path, use_gpu: bool = False) -> None: - import onnxruntime as ort - - providers = gpu_providers() if use_gpu else ["CPUExecutionProvider"] - self._enc_session = ort.InferenceSession(str(enc_path), providers=providers) - self._dec_session = ort.InferenceSession(str(dec_path), providers=providers) + def __init__(self, enc_path: Path, dec_path: Path, coord: ProviderCoordinator) -> None: + self._enc_session = ResilientOnnxSession("sam_enc", enc_path, coord) + self._dec_session = ResilientOnnxSession("sam_dec", dec_path, coord) + self._decoder_input_shapes = { + inp.name: inp.shape for inp in self._dec_session.get_inputs() + } + self._decoder_requires_padded_im_size = "padded_im_size" in self._decoder_input_shapes + self._supports_prompt_batching = self._detect_prompt_batch_support() + self._batch_unsupported_logged = False _provs = self._enc_session.get_providers() self.device = "ONNX/GPU" if is_gpu_active(_provs) else "ONNX/CPU" _active = _provs[0] if _provs else "unknown" - print(f"[OnnxSamPredictor] Loaded encoder+decoder Active provider: {_active} all providers: {_provs}") + debug(f"[OnnxSamPredictor] Loaded encoder+decoder Active provider: {_active} all providers: {_provs}") + debug(f"[OnnxSamPredictor] Prompt batching support: {self._supports_prompt_batching}") + debug(f"[OnnxSamPredictor] Decoder requires padded_im_size: {self._decoder_requires_padded_im_size}") + debug(f"[OnnxSamPredictor] Encoder fixed input HW: {self._encoder_fixed_hw()}") + + def _detect_prompt_batch_support(self) -> bool: + """ + Infer whether decoder graph supports prompt batching (N > 1) by checking + input tensor batch dimensions. If any prompt-related input has a fixed + first dimension of 1, treat batching as unsupported. + """ + try: + inputs = {inp.name: inp.shape for inp in self._dec_session.get_inputs()} + except Exception: + return False + + def _first_dim(name: str): + shape = inputs.get(name) + if not shape or len(shape) == 0: + return None + return shape[0] + + for name in ("point_coords", "point_labels", "mask_input", "has_mask_input", "orig_im_size"): + d0 = _first_dim(name) + if isinstance(d0, int) and d0 == 1: + return False + return True + + def _decoder_input_rank(self, name: str) -> int: + shape = self._decoder_input_shapes.get(name) + return len(shape) if shape is not None else 0 + + def _build_decoder_inputs( + self, + image_embeddings: np.ndarray, + interm_embeddings: np.ndarray, + point_coords: np.ndarray, + point_labels: np.ndarray, + batch: int, + resized_hw: tuple[int, int], + original_hw: tuple[int, int], + ) -> dict[str, np.ndarray]: + orig_h, orig_w = original_hw + resized_h, resized_w = resized_hw + + feed: dict[str, np.ndarray] = { + "image_embeddings": image_embeddings, + "interm_embeddings": interm_embeddings, + "point_coords": point_coords.astype(np.float32), + "point_labels": point_labels.astype(np.float32), + "mask_input": np.zeros((batch, 1, 256, 256), dtype=np.float32), + } + + # Old exports often accept (G,), newer exports require (G,1). + has_mask_rank = self._decoder_input_rank("has_mask_input") + if has_mask_rank == 2: + feed["has_mask_input"] = np.zeros((batch, 1), dtype=np.float32) + else: + feed["has_mask_input"] = np.zeros((batch,), dtype=np.float32) + + # Old exports often accept (2,), newer exports require (G,2). + orig_rank = self._decoder_input_rank("orig_im_size") + if orig_rank == 1: + feed["orig_im_size"] = np.array([orig_h, orig_w], dtype=np.float32) + else: + feed["orig_im_size"] = np.tile( + np.array([[orig_h, orig_w]], dtype=np.float32), + (batch, 1), + ) + + # New decoder export requires resized (pre-pad) H,W for each prompt group. + if self._decoder_requires_padded_im_size: + feed["padded_im_size"] = np.tile( + np.array([[resized_h, resized_w]], dtype=np.float32), + (batch, 1), + ) + + return feed @staticmethod def _resize_longest_side(image: np.ndarray, target: int) -> np.ndarray: @@ -819,6 +840,20 @@ def _resize_longest_side(image: np.ndarray, target: int) -> np.ndarray: new_h, new_w = int(round(h * scale)), int(round(w * scale)) return cv2.resize(image, (new_w, new_h), interpolation=cv2.INTER_LINEAR) + def _encoder_fixed_hw(self) -> tuple[int, int] | None: + """Return fixed encoder (H, W) if the ONNX input shape is static, else None.""" + try: + shape = self._enc_session.get_inputs()[0].shape + except Exception: + return None + if not shape or len(shape) != 4: + return None + h = shape[2] + w = shape[3] + if isinstance(h, int) and isinstance(w, int): + return (h, w) + return None + def encode(self, img_np: np.ndarray) -> tuple: """ Encode an image to SAM embeddings. Called once per image; reuse @@ -834,14 +869,23 @@ def encode(self, img_np: np.ndarray) -> tuple: (image_embeddings, interm_embeddings, resized_hw, original_hw) """ orig_h, orig_w = img_np.shape[:2] - resized = self._resize_longest_side(img_np, self._IMG_SIZE) - resized_h, resized_w = resized.shape[:2] - - img = resized.astype(np.float32) - pad_h = self._IMG_SIZE - resized_h - pad_w = self._IMG_SIZE - resized_w - img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0))) # HxWx3 - img = img.transpose(2, 0, 1)[np.newaxis] # [1, 3, 1024, 1024] + fixed_hw = self._encoder_fixed_hw() + if fixed_hw is not None: + # New exports may pin encoder input to a fixed non-square size. + # Feed exactly what the graph declares. + fixed_h, fixed_w = fixed_hw + resized = cv2.resize(img_np, (fixed_w, fixed_h), interpolation=cv2.INTER_LINEAR) + resized_h, resized_w = resized.shape[:2] + img = resized.astype(np.float32) + img = img.transpose(2, 0, 1)[np.newaxis] # [1, 3, H_fixed, W_fixed] + else: + resized = self._resize_longest_side(img_np, self._IMG_SIZE) + resized_h, resized_w = resized.shape[:2] + img = resized.astype(np.float32) + pad_h = self._IMG_SIZE - resized_h + pad_w = self._IMG_SIZE - resized_w + img = np.pad(img, ((0, pad_h), (0, pad_w), (0, 0))) # HxWx3 + img = img.transpose(2, 0, 1)[np.newaxis] # [1, 3, 1024, 1024] image_embeddings, interm_embeddings = self._enc_session.run(None, {"input_image": img}) return image_embeddings, interm_embeddings, (resized_h, resized_w), (orig_h, orig_w) @@ -877,23 +921,82 @@ def decode_box( point_coords = box_pts[np.newaxis] # (1, 2, 2) point_labels = np.array([[2.0, 3.0]], dtype=np.float32) # TL=2, BR=3 - mask_input = np.zeros((1, 1, 256, 256), dtype=np.float32) - has_mask = np.array([0.0], dtype=np.float32) - orig_im_size = np.array([orig_h, orig_w], dtype=np.float32) # H, W - - masks_out, iou_out, _ = self._dec_session.run(None, { - "image_embeddings": image_embeddings, - "interm_embeddings": interm_embeddings, - "point_coords": point_coords, - "point_labels": point_labels, - "mask_input": mask_input, - "has_mask_input": has_mask, - "orig_im_size": orig_im_size, - }) + feed = self._build_decoder_inputs( + image_embeddings=image_embeddings, + interm_embeddings=interm_embeddings, + point_coords=point_coords, + point_labels=point_labels, + batch=1, + resized_hw=resized_hw, + original_hw=original_hw, + ) + masks_out, iou_out, _ = self._dec_session.run(None, feed) mask = masks_out[0, 0] > 0.0 iou = float(iou_out[0, 0]) return mask, iou + def decode_boxes( + self, + image_embeddings: np.ndarray, + interm_embeddings: np.ndarray, + boxes_xyxy: list[tuple[int, int, int, int]], + resized_hw: tuple, + original_hw: tuple, + ) -> list[tuple[np.ndarray, float]]: + """ + Batch decode multiple bounding-box prompts to masks for one image. + + Returns: + List of (mask bool HxW at original resolution, iou float), one per box. + """ + if not boxes_xyxy: + return [] + if len(boxes_xyxy) == 1: + return [self.decode_box(image_embeddings, interm_embeddings, boxes_xyxy[0], resized_hw, original_hw)] + if not self._supports_prompt_batching: + if not self._batch_unsupported_logged: + debug( + "[SAM-HQ] decoder ONNX export has fixed batch=1 on prompt inputs; " + "using per-box decode path." + ) + self._batch_unsupported_logged = True + return [ + self.decode_box(image_embeddings, interm_embeddings, box_xyxy, resized_hw, original_hw) + for box_xyxy in boxes_xyxy + ] + + orig_h, orig_w = original_hw + resized_h, resized_w = resized_hw + batch = len(boxes_xyxy) + + box_pts = np.zeros((batch, 2, 2), dtype=np.float32) + for i, (x1, y1, x2, y2) in enumerate(boxes_xyxy): + box_pts[i, 0, 0] = x1 * resized_w / orig_w + box_pts[i, 0, 1] = y1 * resized_h / orig_h + box_pts[i, 1, 0] = x2 * resized_w / orig_w + box_pts[i, 1, 1] = y2 * resized_h / orig_h + + point_coords = box_pts + point_labels = np.tile(np.array([[2.0, 3.0]], dtype=np.float32), (batch, 1)) + # Decoder repeats one image embedding across prompt groups internally. + feed = self._build_decoder_inputs( + image_embeddings=image_embeddings, + interm_embeddings=interm_embeddings, + point_coords=point_coords, + point_labels=point_labels, + batch=batch, + resized_hw=resized_hw, + original_hw=original_hw, + ) + masks_out, iou_out, _ = self._dec_session.run(None, feed) + + results: list[tuple[np.ndarray, float]] = [] + for i in range(batch): + mask = masks_out[i, 0] > 0.0 + iou = float(iou_out[i, 0]) + results.append((mask, iou)) + return results + class SpeciesNetSAMHQWrapper: """Detector/classifier/ensemble from SpeciesNet; masks from SAM-HQ (ViT-Tiny ONNX) box prompts.""" @@ -903,6 +1006,9 @@ def __init__( max_bird_crops: int = _DEFAULT_MAX_BIRD_CROPS, use_gpu: bool = True, detector_name: str = DEFAULT_DETECTOR_NAME, + *, + status_cb: Optional[Callable[[str], None]] = None, + resilience_cfg: Optional[ResilienceConfig] = None, ): self.max_bird_crops = _coerce_max_bird_crops(max_bird_crops) self.use_gpu = bool(use_gpu) @@ -912,6 +1018,17 @@ def __init__( self.classifier: Optional[OnnxClassifier] = None self.ensemble = None self.model_name: Optional[str] = None + self._status_cb = status_cb + self._coord = ProviderCoordinator( + user_gpu_enabled=self.use_gpu, + cfg=resilience_cfg or ResilienceConfig(), + status_cb=status_cb, + ) + self._coord.register_recreate_callback(self.recreate_sessions) + + @property + def coord(self) -> ProviderCoordinator: + return self._coord def _ensure_speciesnet(self) -> None: from ._speciesnet_ensemble import LocalSpeciesNetEnsemble as SpeciesNetEnsemble @@ -919,18 +1036,18 @@ def _ensure_speciesnet(self) -> None: if self.detector is None or self.classifier is None: self.model_name = _speciesnet_bundle_model_name() detector_path = _resolve_detector_onnx_path(self.detector_name) - if self.detector_name in _MDV5A_DETECTOR_NAMES: - self.detector = OnnxMDv5Detector(detector_path, use_gpu=self.use_gpu) - elif self.detector_name in _YOLOV9_DETECTOR_NAMES: - self.detector = OnnxMDv6MitYoloV9Detector(detector_path, use_gpu=self.use_gpu) + if self.detector_name == "mdv5a": + self.detector = OnnxMDv5Detector(detector_path, self._coord) + elif self.detector_name in _MDV1000_CEDAR_DETECTOR_NAMES: + self.detector = OnnxMDv1000CedarDetector(detector_path, self._coord) else: - self.detector = OnnxMDv6Detector(detector_path, use_gpu=self.use_gpu) + raise ValueError(f"Unsupported detector name: {self.detector_name!r}") onnx_path = SPECIESNET_MODEL_DIR / "speciesNet_v4.0.1a.onnx" labels_path = SPECIESNET_MODEL_DIR / "always_crop_99710272_22x8_v12_epoch_00148.labels.20251208.txt" - self.classifier = OnnxClassifier(onnx_path, labels_path, use_gpu=self.use_gpu) - print(f"[SpeciesNetSAMHQ] Detector model : {self.detector_name} ({detector_path.name})") - print(f"[SpeciesNetSAMHQ] Detector : {self.detector.device}") - print(f"[SpeciesNetSAMHQ] Classifier : ONNX providers={self.classifier.providers_used}") + self.classifier = OnnxClassifier(onnx_path, labels_path, self._coord) + debug(f"[SpeciesNetSAMHQ] Detector model : {self.detector_name} ({detector_path.name})") + debug(f"[SpeciesNetSAMHQ] Detector : {self.detector.device}") + debug(f"[SpeciesNetSAMHQ] Classifier : ONNX providers={self.classifier.providers_used}") if self.ensemble is None: self.ensemble = SpeciesNetEnsemble(self.model_name, geofence=False) @@ -952,24 +1069,43 @@ def _ensure_sam(self) -> None: f"SAM-HQ decoder ONNX not found at: {SAM_DEC_ONNX_PATH}\n" "Place sam_hq_vit_tiny_decoder.onnx under models/speciesnet/." ) - # Prefer SAM-HQ on GPU (DirectML on Windows, CoreML on macOS) when enabled; - # fallback to CPU only if GPU session initialization fails. try: - self.predictor = OnnxSamPredictor( - SAM_ENC_ONNX_PATH, - SAM_DEC_ONNX_PATH, - use_gpu=self.use_gpu, - ) + self.predictor = OnnxSamPredictor(SAM_ENC_ONNX_PATH, SAM_DEC_ONNX_PATH, self._coord) except Exception as e: - if not self.use_gpu: + # If init failed on GPU, demote and try once on CPU. This preserves + # the behavior of the old SAM-only fallback for any device that + # can build a CPU session even when GPU init throws. + if self._coord.on_run_failure(e) == FailureAction.RECREATE_AND_RETRY: + warn(f"[SpeciesNetSAMHQ] SAM-HQ GPU init failed, falling back to CPU: {e}") + self.predictor = OnnxSamPredictor(SAM_ENC_ONNX_PATH, SAM_DEC_ONNX_PATH, self._coord) + else: raise - print(f"[SpeciesNetSAMHQ] SAM-HQ GPU init failed, falling back to CPU: {e}") - self.predictor = OnnxSamPredictor( - SAM_ENC_ONNX_PATH, - SAM_DEC_ONNX_PATH, - use_gpu=False, - ) - print(f"[SpeciesNetSAMHQ] SAM-HQ : {self.predictor.device}") + debug(f"[SpeciesNetSAMHQ] SAM-HQ : {self.predictor.device}") + + def recreate_sessions(self, target_use_gpu: bool) -> None: + """Rebuild every ONNX session registered with the coordinator on its + current provider. Called by ``ProviderCoordinator`` when demoting + GPU→CPU or promoting CPU→GPU. Coordinator state must already reflect + the target provider before this is called, since each session's + ``_rebuild`` consults ``providers_for(...)``. + + Walks the coordinator's session registry, which covers BOTH the + wrapper's detector/classifier/SAM sessions AND any pipeline-owned + sessions (BirdSpeciesClassifier, QualityClassifier) — they all share + the same coord. This avoids the partial-recovery failure mode where + the wrapper's sessions migrate to CPU but a separately-owned session + stays on the dead provider, throwing on every subsequent image. + """ + self.use_gpu = bool(target_use_gpu) + self._coord.recreate_all() + + def update_status_cb(self, status_cb: Optional[Callable[[str], None]]) -> None: + """Rebind the coordinator's status callback when a wrapper is reused + across folders so notifications target the active folder, not the + first folder that constructed the wrapper. + """ + self._status_cb = status_cb + self._coord.update_status_cb(status_cb) def _run_ensemble_for_item( self, @@ -997,17 +1133,62 @@ def get_prediction( threshold: float = 0.75, mask_threshold: float = 0.5, ): - """Run SpeciesNet + SAM-HQ. - - Args: - image_data: RGB uint8 image. - image_path: Path passed to SpeciesNet (must exist on disk). - wildlife_enabled: When False, non-aves animals are omitted. - threshold: MegaDetector minimum confidence for an ``animal`` detection. - mask_threshold: Unused (legacy Mask R-CNN pixel threshold); retained for API compatibility. + """Run SpeciesNet + SAM-HQ with provider-resilience retry. - Returns: - (masks, pred_boxes, pred_class, pred_score) — detection/mask contract used by the pipeline. + On a known "session is now corpse" error (DML device-removed, CoreML + cold-start path loss, etc.) the coordinator demotes GPU→CPU, the + wrapper rebuilds every loaded session on the new provider, and the + same image is retried once. Any other exception propagates immediately + so the pipeline's per-image catcher marks it errored as before. + """ + # Between-image promotion attempt: if we've been on CPU for long enough, + # try GPU again before this image. Failure here just stays on CPU; the + # actual inference still happens below. + if self._coord.should_try_promote(): + self._coord.attempt_promotion() + + last_exc: Optional[BaseException] = None + for attempt in range(self._coord.cfg.max_attempts_per_image): + try: + result = self._get_prediction_inner( + image_data, + image_path, + wildlife_enabled=wildlife_enabled, + threshold=threshold, + mask_threshold=mask_threshold, + ) + self._coord.on_run_success() + return result + except Exception as e: + last_exc = e + if attempt + 1 >= self._coord.cfg.max_attempts_per_image: + raise + action = self._coord.on_run_failure(e) + if action != FailureAction.RECREATE_AND_RETRY: + raise + try: + self.recreate_sessions(target_use_gpu=False) + except Exception: + # Rebuild itself failed — there's nothing more we can do + # here. Surface the original inference error. + raise last_exc + # Loop and retry on CPU. + # Defensive: max_attempts_per_image must be >= 1. + if last_exc is not None: + raise last_exc + return [], [], [], [] + + def _get_prediction_inner( + self, + image_data: np.ndarray, + image_path: str | Path, + *, + wildlife_enabled: bool = True, + threshold: float = 0.75, + mask_threshold: float = 0.5, + ): + """Body of ``get_prediction``. Single attempt, no retry — the resilience + loop in ``get_prediction`` is the only caller in production code. """ _ = mask_threshold # SAM-HQ path does not use Mask R-CNN mask pixel threshold; UI keeps knob for compatibility. @@ -1050,13 +1231,13 @@ def get_prediction( animal_dets = prefilter_overlapping_md_boxes(animal_dets) pre_nms_dropped = pre_nms_count - len(animal_dets) if pre_nms_dropped > 0: - print( + debug( f"[SpeciesNet] pre-classifier NMS: dropped {pre_nms_dropped} of" f" {pre_nms_count} MegaDetector proposals (IoU>={_PRE_CLASSIFIER_IOU}" f" or containment>={_PRE_CLASSIFIER_CONTAINMENT})" ) - print( + debug( f"[SpeciesNet] {os.path.basename(fp)} animals -> classifier/SAM: {len(animal_dets)}" f" (detector_threshold={detector_threshold:.2f}, total proposals={len(detections)}" f"{f', pre-NMS dropped {pre_nms_dropped}' if pre_nms_dropped else ''})" @@ -1064,20 +1245,49 @@ def get_prediction( bird_rows: list[dict[str, Any]] = [] wildlife_rows: list[dict[str, Any]] = [] + sam_decode_candidates: list[dict[str, Any]] = [] + planned_bird_count = 0 + planned_wildlife_count = 0 if self.predictor is None: return [], [], [], [] # Encode once — all detections on this image share the same embeddings image_embeddings, interm_embeddings, resized_hw, original_hw = self.predictor.encode(image_data) + debug( + f"[SAM-HQ] encoder: image={os.path.basename(fp)} mode=single-per-image " + f"detections={len(animal_dets)}" + ) + + # Batch classifier preprocess + ONNX inference for all detections in this image. + classifier_preds_by_idx: dict[int, dict[str, Any]] = {} + if animal_dets: + md_bboxes = [det.get("bbox", [0.0, 0.0, 0.0, 0.0]) for det in animal_dets] + bbox_objs = [BBox(*md_bbox) for md_bbox in md_bboxes] + preprocessed_many = self.classifier.preprocess_many(img_pil, bbox_objs) + filepaths_many = [f"{fp}#det{i}" for i in range(len(animal_dets))] + debug( + f"[SpeciesNet] batch classifier: image={os.path.basename(fp)} " + f"batch_size={len(preprocessed_many)}" + ) + cls_preds_many = self.classifier.predict_many(filepaths_many, preprocessed_many) + debug( + f"[SpeciesNet] batch classifier complete: image={os.path.basename(fp)} " + f"predictions={len(cls_preds_many)}" + ) + for i, cls_pred in enumerate(cls_preds_many): + classifier_preds_by_idx[i] = cls_pred for det_idx, det in enumerate(animal_dets): md_bbox = det.get("bbox", [0.0, 0.0, 0.0, 0.0]) label = str(det.get("label", "animal")) conf = float(det.get("conf", 0.0)) - cls_input = self.classifier.preprocess(img_pil, bboxes=[BBox(*md_bbox)]) - cls_pred = self.classifier.predict(fp, cls_input) + cls_pred = classifier_preds_by_idx.get(det_idx) + if cls_pred is None: + # Defensive fallback (should not happen): preserve old single-item path. + cls_input = self.classifier.preprocess(img_pil, bboxes=[BBox(*md_bbox)]) + cls_pred = self.classifier.predict(fp, cls_input) cls_info = cls_pred.get("classifications", {}) fp_det = f"{fp}#det{det_idx}" @@ -1091,7 +1301,7 @@ def get_prediction( pred_score = float(ensemble_det.get("prediction_score", conf)) pred_source = str(ensemble_det.get("prediction_source", "")) except Exception as e: - print("[SpeciesNet] ensemble error, fallback to classifier top-1:", e) + warn("[SpeciesNet] ensemble error, fallback to classifier top-1:", e) classes = cls_info.get("classes", []) scores = cls_info.get("scores", []) pred_raw = str(classes[0]) if classes else "unknown" @@ -1107,7 +1317,7 @@ def get_prediction( if should_skip_confident_no_cv_classifier(cls_info, detector_threshold): cutoff = 1.0 - float(detector_threshold) - print( + debug( f"[SpeciesNet] det {det_idx} SKIPPED — top classifier label is" f" 'no cv result' with score > {cutoff:.2f} (1 − detector threshold)" f" (detector conf={conf:.2f})" @@ -1116,13 +1326,13 @@ def get_prediction( if is_ambiguous_generic_taxonomy(pred_raw): bb, bo = bird_vs_wildlife_classifier_scores(cls_info) - print( + debug( f"[SpeciesNet] det {det_idx} conf={conf:.2f} pred={pred_raw!r}" f" ambiguous: bird_max={bb:.3f} other={bo:.3f}" f" -> route={route} label={pred_label}" ) else: - print( + debug( f"[SpeciesNet] det {det_idx} conf={conf:.2f} pred={pred_raw!r}" f" score={pred_score:.3f} route={route} label={pred_label}" f" via={pred_source}" @@ -1146,7 +1356,7 @@ def get_prediction( ) elif not wildlife_enabled: reason = "non-bird wildlife disabled" - print( + debug( f"[SpeciesNet] det {det_idx} SKIPPED — {reason}" f" (conf={conf:.2f}, pred={pred_raw!r})" ) @@ -1156,7 +1366,7 @@ def get_prediction( # SpeciesNet can prune false positives. Require the ensemble/classifier # score to clear the same user-facing threshold as the detector. if pred_score < detector_threshold: - print( + debug( f"[SpeciesNet] det {det_idx} SKIPPED — classifier pred_score" f" {pred_score:.3f} < threshold {detector_threshold:.2f}" f" (detector conf={conf:.2f}, pred={pred_raw!r})" @@ -1165,35 +1375,104 @@ def get_prediction( x1, y1, x2, y2 = _md_bbox_to_pixel_box(md_bbox, w, h) xi1, yi1, xi2, yi2 = _clip_xyxy(x1, y1, x2, y2, w, h) + resolved_class = pred_label if route == "wildlife" else "bird" + if resolved_class == "bird": + if planned_bird_count >= self.max_bird_crops: + debug( + f"[SpeciesNet] det {det_idx} SKIPPED — bird crop cap reached " + f"({self.max_bird_crops}) before SAM decode" + ) + continue + planned_bird_count += 1 + else: + if planned_wildlife_count >= self.max_bird_crops: + debug( + f"[SpeciesNet] det {det_idx} SKIPPED — wildlife crop cap reached " + f"({self.max_bird_crops}) before SAM decode" + ) + continue + planned_wildlife_count += 1 + sam_decode_candidates.append( + { + "prompt_box": (xi1, yi1, xi2, yi2), + "pred_boxes": _pixel_box_to_pipeline_box(x1, y1, x2, y2), + "pred_class": resolved_class, + "pred_score": pred_score, + "detector_confidence": conf, + } + ) + if sam_decode_candidates: + sam_results: list[tuple[np.ndarray, float]] = [] try: - mask, _iou = self.predictor.decode_box( - image_embeddings, interm_embeddings, - (xi1, yi1, xi2, yi2), resized_hw, original_hw, + if getattr(self.predictor, "_supports_prompt_batching", False): + debug( + f"[SAM-HQ] batch decode: image={os.path.basename(fp)} " + f"batch_size={len(sam_decode_candidates)}" + ) + else: + debug( + f"[SAM-HQ] decode: image={os.path.basename(fp)} " + f"boxes={len(sam_decode_candidates)} mode=per-box(fixed-batch-model)" + ) + sam_results = self.predictor.decode_boxes( + image_embeddings, + interm_embeddings, + [c["prompt_box"] for c in sam_decode_candidates], + resized_hw, + original_hw, ) + if getattr(self.predictor, "_supports_prompt_batching", False): + debug( + f"[SAM-HQ] batch decode complete: image={os.path.basename(fp)} " + f"decoded={len(sam_results)}" + ) + else: + debug( + f"[SAM-HQ] decode complete: image={os.path.basename(fp)} " + f"decoded={len(sam_results)} mode=per-box(fixed-batch-model)" + ) except Exception as e: - print("[SAM-HQ] mask failed:", e) - continue - - row = { - "mask": mask, - "pred_boxes": _pixel_box_to_pipeline_box(x1, y1, x2, y2), - "pred_class": pred_label if route == "wildlife" else "bird", - "pred_score": pred_score, - "detector_confidence": conf, - } - if route == "bird": - bird_rows.append(row) - else: - wildlife_rows.append(row) + warn(f"[SAM-HQ] batch decode failed, falling back to per-box decode: {e}") + sam_results = [] + for c in sam_decode_candidates: + try: + sam_results.append( + self.predictor.decode_box( + image_embeddings, + interm_embeddings, + c["prompt_box"], + resized_hw, + original_hw, + ) + ) + except Exception as e2: + warn(f"[SAM-HQ] mask failed for one box: {e2}") + sam_results.append((None, 0.0)) + + for candidate, sam_out in zip(sam_decode_candidates, sam_results): + mask = sam_out[0] + if mask is None: + continue + row = { + "mask": mask, + "pred_boxes": candidate["pred_boxes"], + "pred_class": candidate["pred_class"], + "pred_score": candidate["pred_score"], + "detector_confidence": candidate["detector_confidence"], + } + if candidate["pred_class"] == "bird": + bird_rows.append(row) + else: + wildlife_rows.append(row) if len(bird_rows) > self.max_bird_crops: - print( + debug( f"[SpeciesNet] crop limit: keeping {self.max_bird_crops} of" f" {len(bird_rows)} bird detections" ) if len(wildlife_rows) > self.max_bird_crops: - print( + debug( f"[SpeciesNet] crop limit: keeping {self.max_bird_crops} of" f" {len(wildlife_rows)} wildlife detections" ) @@ -1223,7 +1502,7 @@ def get_prediction( ) post_overlap_count = len(result[2]) if result[2] is not None else 0 if post_overlap_count < pre_overlap_count: - print( + debug( f"[SpeciesNet] overlap filter: removed {pre_overlap_count - post_overlap_count}" f" of {pre_overlap_count} detections (IoU>={_HEAVY_OVERLAP_IOU}" f" or containment>={_HEAVY_OVERLAP_CONTAINMENT})" diff --git a/analyzer/kestrel_analyzer/pipeline.py b/analyzer/kestrel_analyzer/pipeline.py index 0cfb82f9..7fb0ec0d 100644 --- a/analyzer/kestrel_analyzer/pipeline.py +++ b/analyzer/kestrel_analyzer/pipeline.py @@ -46,13 +46,20 @@ from .logging_utils import get_log_path, log_event, log_exception, log_warning try: - from ..settings_utils import load_persisted_settings -except ImportError: + from ..settings_utils import load_persisted_settings, debug as _debug, info as _info +except (ImportError, ValueError): + # Top-level package case: cli.py adds analyzer/ to sys.path and imports + # ``kestrel_analyzer`` as a root package, so the relative ``..`` walks + # beyond it. The bare-name fallback works because ``settings_utils`` is + # then directly importable from sys.path. try: - from analyzer.settings_utils import load_persisted_settings + from settings_utils import load_persisted_settings, debug as _debug, info as _info # type: ignore except ImportError: load_persisted_settings = None + def _debug(*_a, **_kw): pass + def _info(*_a, **_kw): pass from .ml.speciesnet_sam_hq import SpeciesNetSAMHQWrapper +from .ml.provider_coordinator import ResilienceConfig from .ml.bird_species import BirdSpeciesClassifier from .ml.quality import QualityClassifier @@ -149,6 +156,7 @@ def _decode_image(self, image_path: str, raw_file: str) -> dict: "meter_warning": None, "orientation": "unknown", "capture_time": None, + "capture_time_warning": None, "error": None, } try: @@ -182,8 +190,13 @@ def _decode_image(self, image_path: str, raw_file: str) -> dict: try: ct = get_capture_time(image_path) result["capture_time"] = ct - except Exception: - pass + except Exception as ct_exc: + # Scene grouping falls back to AKAZE feature similarity when + # capture_time is missing; not fatal. We record the reason so + # the main loop can surface it once via log_warning. + result["capture_time_warning"] = ( + f"Capture-time extraction failed: {ct_exc}" + ) except Exception as exc: result["error"] = exc @@ -274,10 +287,9 @@ def _submit_all() -> None: submit_thread.start() idx = 0 - print( + _debug( f"[decode-queue] starting: files={total} workers={max_workers} " - f"buffer={max_buffered}", - flush=True, + f"buffer={max_buffered}" ) while True: @@ -300,11 +312,10 @@ def _submit_all() -> None: decode_ms_str = f"{decode_ms:.0f}" if decode_ms is not None else "?" inflight_now = max(0, snap_submitted - snap_completed) ahead = max(0, snap_submitted - idx) - print( + _debug( f"[decode-queue] idx={idx}/{total} " f"inflight={inflight_now} ahead={ahead} " - f"decode_ms={decode_ms_str} wait_ms={wait_ms:.0f}", - flush=True, + f"decode_ms={decode_ms_str} wait_ms={wait_ms:.0f}" ) yield decoded @@ -333,10 +344,9 @@ def _submit_all() -> None: ) except Exception: pass - print( + _debug( f"[decode-queue] done: files={total} peak_inflight={peak_inflight} " - f"avg_decode_ms={avg_decode:.0f} avg_wait_ms={avg_wait:.0f}", - flush=True, + f"avg_decode_ms={avg_decode:.0f} avg_wait_ms={avg_wait:.0f}" ) def load_models( @@ -357,29 +367,57 @@ def load_models( and str(getattr(self.sn_sam, "detector_name", DEFAULT_DETECTOR_NAME)) == self.detector_name ) if mask_ready_for_cap and self.species_clf and self.quality_clf: + # Wrapper is being reused — repoint its status_cb at the active + # folder so coord notifications (e.g., "Switched to CPU after GPU + # error") land on this folder's queue item, not whichever folder + # first constructed the wrapper. + try: + self.sn_sam.update_status_cb(status_cb) + except Exception: + pass return if status_cb: status_cb( f"Loading models (detector={self.detector_name})... This may take a while on first run." ) if not mask_ready_for_cap: + resilience_cfg = None + if callable(load_persisted_settings): + try: + resilience_cfg = ResilienceConfig.from_settings(load_persisted_settings() or {}) + except Exception: + resilience_cfg = None self.sn_sam = SpeciesNetSAMHQWrapper( max_bird_crops=max_bird_crops, use_gpu=self.use_gpu, detector_name=self.detector_name, + status_cb=status_cb, + resilience_cfg=resilience_cfg, ) + # The new wrapper has a fresh coord. Any existing species/quality + # classifiers were registered with the OLD coord, so drop them and + # rebuild below — otherwise their sessions wouldn't migrate when + # the new coord recreates. + self.species_clf = None + self.quality_clf = None + # Share the wrapper's coordinator with the species/quality classifiers + # so demote/promote rebuilds every session in the run together. Without + # this, a DML/CoreML failure recovers the wrapper but leaves these two + # sessions on the dead provider — every later image errors at the + # species step and the run looks "stuck on error". + coord = self.sn_sam.coord if not self.species_clf: self.species_clf = BirdSpeciesClassifier( str(SPECIESCLASSIFIER_PATH), str(SPECIESCLASSIFIER_LABELS), - self.use_gpu, + coord, models_dir=str(MODELS_DIR), ) if not self.quality_clf: self.quality_clf = QualityClassifier( str(QUALITYCLASSIFIER_PATH), normalization_data_path=str(QUALITY_NORMALIZATION_DATA_PATH), - use_gpu=self.use_gpu, + coord=coord, ) if status_cb: status_cb("Models loaded. Processing started.") @@ -392,11 +430,16 @@ def process_folder( callbacks: Optional[Dict[str, Callable]] = None, analyzer_name: str = "pipeline", wildlife_enabled: bool = True, + species_detection_enabled: bool = True, detection_threshold: float = 0.25, scene_time_threshold: float = 1.0, mask_threshold: float = 0.5, max_bird_crops: int = 5, parallel_prefetch: int = 3, + retry_errored: bool = False, + exposure_quality: Optional[str] = None, + thumbnail_max_width: Optional[int] = None, + thumbnail_jpeg_compression: Optional[float] = None, ) -> None: callbacks = callbacks or {} status_cb = callbacks.get("on_status") @@ -423,6 +466,11 @@ def process_folder( rating_thresholds = None rating_profile = "balanced" + # Caller-provided overrides win over persisted settings. None means + # "fall back to settings.json (or default)". + eq_override = exposure_quality + tmw_override = thumbnail_max_width + tjc_override = thumbnail_jpeg_compression exposure_quality = "balanced" thumbnail_max_width = 1200 thumbnail_jpeg_compression = 0.75 @@ -453,6 +501,20 @@ def process_folder( thumbnail_jpeg_compression = 0.75 except Exception: rating_thresholds = None + if eq_override is not None: + raw_eq = str(eq_override).strip().lower() + if raw_eq in {'lenient', 'balanced', 'aggressive'}: + exposure_quality = raw_eq + if tmw_override is not None: + try: + thumbnail_max_width = int(tmw_override) + except (TypeError, ValueError): + pass + if tjc_override is not None: + try: + thumbnail_jpeg_compression = float(tjc_override) + except (TypeError, ValueError): + pass thumbnail_max_width = max(400, min(2400, thumbnail_max_width)) thumbnail_jpeg_compression = max(0.5, min(1.0, thumbnail_jpeg_compression)) thumbnail_jpeg_quality = int(round(thumbnail_jpeg_compression * 100.0)) @@ -533,8 +595,36 @@ def _showwarning(message, category, filename, lineno, file=None, line=None): stage_ctx["stage"] = "load_database" database, db_path = load_database(kestrel_dir, analyzer_name, log_path=self._log_path) + # When retrying errored images, capture each errored row's previously- + # computed values BEFORE dropping it. Many errors hit during detection + # or later, after similarity / capture_time / orientation are already + # filled in — the retry can reuse those instead of recomputing AKAZE + # against the wrong neighbor (the rolling ``previous_image`` would be + # the last surviving DB row, not the alphabetical predecessor). + errored_rows_by_filename: Dict[str, dict] = {} + if retry_errored and not database.empty and "species" in database.columns: + errored_mask = database["species"].astype(str) == "Error" + errored_count = int(errored_mask.sum()) + if errored_count > 0: + if status_cb: + status_cb(f"Including {errored_count} previously errored image(s) for retry.") + for _, row in database.loc[errored_mask].iterrows(): + try: + errored_rows_by_filename[str(row["filename"])] = row.to_dict() + except Exception: + continue + # Drop errored rows so the pipeline's later concat doesn't + # produce duplicate (filename, species=Error) rows. The + # retried images will append fresh rows below. + database = database.loc[~errored_mask].copy() + save_database(database, db_path) + processed_set = set(database["filename"].values) new_files = [f for f in files if f not in processed_set] + # Index of every file in the sorted ``files`` list, so retry + # iterations can look up each errored image's alphabetical + # predecessor in O(1) for the previous_image reset path. + files_idx: Dict[str, int] = {f: i for i, f in enumerate(files)} processed_count = len(files) - len(new_files) total = len(files) if progress_cb: @@ -641,6 +731,13 @@ def _showwarning(message, category, filename, lineno, file=None, line=None): stage=stage_ctx["stage"], context={"file": raw_file, "folder": folder}, ) + if decoded.get("capture_time_warning"): + log_warning( + self._log_path, + decoded["capture_time_warning"], + stage=stage_ctx["stage"], + context={"file": raw_file, "folder": folder}, + ) current_orientation = decoded["orientation"] entry["orientation"] = current_orientation @@ -649,65 +746,125 @@ def _showwarning(message, category, filename, lineno, file=None, line=None): entry["capture_time"] = ct.isoformat() if ct is not None else "" stage_ctx["stage"] = "compute_similarity" - timestamp_similar = None - try: - timestamp_similar = compute_similarity_timestamp( - previous_image_path, image_path, - threshold_seconds=scene_time_threshold - ) if previous_image_path else None - except Exception as e: - log_warning( - self._log_path, - f"Timestamp similarity check failed: {e}", - stage=stage_ctx["stage"], - context={"file": raw_file, "folder": folder}, - ) - orientation_changed = ( - previous_orientation is not None - and current_orientation != "unknown" - and previous_orientation != "unknown" - and current_orientation != previous_orientation + preserved_row = errored_rows_by_filename.get(raw_file) + + def _coerce_float(v, default): + try: + f = float(v) + return f if f == f else default # NaN check + except (TypeError, ValueError): + return default + + preserved_feat_sim = _coerce_float(preserved_row.get("feature_similarity"), -1.0) if preserved_row else -1.0 + preserved_was_similar = bool(preserved_row.get("similar", False)) if preserved_row else False + preserved_sim_valid = bool( + preserved_row is not None + and (preserved_feat_sim >= 0.0 or preserved_was_similar) ) - if orientation_changed: - scene_count += 1 - entry.update( - { - "feature_similarity": -1.0, - "feature_confidence": -1.0, - "color_similarity": -1.0, - "color_confidence": -1.0, - "scene_count": scene_count, - "similar": False, - } - ) - elif timestamp_similar is True: - # Images captured within the same second — treat as similar, skip AKAZE + if preserved_sim_valid: + # Reuse similarity from the prior (errored) attempt. The + # previous_image AKAZE was originally computed against is + # gone, but the saved values are still authoritative — + # the source images haven't changed. + preserved_scene = int(_coerce_float(preserved_row.get("scene_count"), scene_count)) entry.update( { - "feature_similarity": -1.0, - "feature_confidence": -1.0, - "color_similarity": -1.0, - "color_confidence": -1.0, - "scene_count": scene_count, - "similar": True, + "feature_similarity": preserved_feat_sim, + "feature_confidence": _coerce_float(preserved_row.get("feature_confidence"), -1.0), + "color_similarity": _coerce_float(preserved_row.get("color_similarity"), -1.0), + "color_confidence": _coerce_float(preserved_row.get("color_confidence"), -1.0), + "scene_count": preserved_scene, + "similar": preserved_was_similar, } ) + # Keep the running scene counter monotonic so subsequent + # genuinely-new scenes get unique IDs. + scene_count = max(scene_count, preserved_scene) else: - similarity = compute_image_similarity_akaze(previous_image, img) - if not similarity["similar"]: - scene_count += 1 - entry.update( - { - "feature_similarity": similarity["feature_similarity"], - "feature_confidence": similarity["feature_confidence"], - "color_similarity": similarity["color_similarity"], - "color_confidence": similarity["color_confidence"], - "scene_count": scene_count, - "similar": similarity["similar"], - } + # No usable preserved values. If this is a retry image, + # reset previous_image to its alphabetical predecessor + # so AKAZE compares against the right neighbor instead + # of whatever rolled in from the last DB row. + if preserved_row is not None: + prev_idx = files_idx.get(raw_file, -1) - 1 + if prev_idx >= 0: + pred_filename = files[prev_idx] + pred_path = os.path.join(folder, pred_filename) + if os.path.exists(pred_path): + try: + pred_img = read_image(pred_path) + except Exception: + pred_img = None + if pred_img is not None: + previous_image = pred_img + previous_image_path = pred_path + previous_orientation = self._get_image_orientation(pred_img) + else: + previous_image = None + previous_image_path = None + previous_orientation = None + + timestamp_similar = None + try: + timestamp_similar = compute_similarity_timestamp( + previous_image_path, image_path, + threshold_seconds=scene_time_threshold + ) if previous_image_path else None + except Exception as e: + log_warning( + self._log_path, + f"Timestamp similarity check failed: {e}", + stage=stage_ctx["stage"], + context={"file": raw_file, "folder": folder}, + ) + + orientation_changed = ( + previous_orientation is not None + and current_orientation != "unknown" + and previous_orientation != "unknown" + and current_orientation != previous_orientation ) + + if orientation_changed: + scene_count += 1 + entry.update( + { + "feature_similarity": -1.0, + "feature_confidence": -1.0, + "color_similarity": -1.0, + "color_confidence": -1.0, + "scene_count": scene_count, + "similar": False, + } + ) + elif timestamp_similar is True: + # Images captured within the same second — treat as similar, skip AKAZE + entry.update( + { + "feature_similarity": -1.0, + "feature_confidence": -1.0, + "color_similarity": -1.0, + "color_confidence": -1.0, + "scene_count": scene_count, + "similar": True, + } + ) + else: + similarity = compute_image_similarity_akaze(previous_image, img) + if not similarity["similar"]: + scene_count += 1 + entry.update( + { + "feature_similarity": similarity["feature_similarity"], + "feature_confidence": similarity["feature_confidence"], + "color_similarity": similarity["color_similarity"], + "color_confidence": similarity["color_confidence"], + "scene_count": scene_count, + "similar": similarity["similar"], + } + ) # Hold the previous image by reference, not by copy. The next # iteration rebinds ``img`` to a fresh decoded array, so the # old array stays alive for AKAZE via this reference. The @@ -935,27 +1092,33 @@ def process_subject_items(indices): for item in items: i = item["index"] if pred_class[i] == "bird": - species_result = self.species_clf.classify(item["species_crop"]) - item["species"] = ( - species_result["top_species_labels"][0] - if len(species_result["top_species_labels"]) - else "Unknown" - ) - item["species_confidence"] = ( - float(species_result["top_species_scores"][0]) - if len(species_result["top_species_scores"]) - else 0.0 - ) - item["family"] = ( - species_result["top_family_labels"][0] - if len(species_result["top_family_labels"]) - else "Unknown" - ) - item["family_confidence"] = ( - float(species_result["top_family_scores"][0]) - if len(species_result["top_family_scores"]) - else 0.0 - ) + if species_detection_enabled: + species_result = self.species_clf.classify(item["species_crop"]) + item["species"] = ( + species_result["top_species_labels"][0] + if len(species_result["top_species_labels"]) + else "Unknown" + ) + item["species_confidence"] = ( + float(species_result["top_species_scores"][0]) + if len(species_result["top_species_scores"]) + else 0.0 + ) + item["family"] = ( + species_result["top_family_labels"][0] + if len(species_result["top_family_labels"]) + else "Unknown" + ) + item["family_confidence"] = ( + float(species_result["top_family_scores"][0]) + if len(species_result["top_family_scores"]) + else 0.0 + ) + else: + item["species"] = "Unknown" + item["species_confidence"] = 0.0 + item["family"] = "Unknown" + item["family_confidence"] = 0.0 else: item["species"] = pred_class[i] item["species_confidence"] = float(pred_score[i]) @@ -1239,18 +1402,18 @@ def process_subject_items(indices): except Exception: pass - # === Post-analysis: compute quality distribution and normalized ratings === + # === Post-analysis: persist database + audit-trail metadata + scene grouping === stage_ctx["stage"] = "post_analysis_normalization" try: - from .ratings import compute_quality_distribution if not database.empty and "quality" in database.columns: - quality_scores = database["quality"].tolist() - distribution = compute_quality_distribution(quality_scores) - - # Save analysis results (no normalized_rating; computed at runtime) save_database(database, db_path) - # Cache quality distribution in kestrel_metadata.json for runtime normalization + # Update kestrel_metadata.json with the analysis-run audit trail + # (render mode, settings snapshot). The frontend reads + # exposure_render_mode / exposure_pipeline_version to pick the + # correct RAW preview path; the rest is a record of which + # parameters produced the cached results, intended for users + # who later want to see what settings produced this folder. metadata_path = os.path.join(kestrel_dir, METADATA_FILENAME) try: import json as _json @@ -1276,23 +1439,17 @@ def process_subject_items(indices): else: render_mode_meta = "legacy_auto_bright_v1" - _meta["quality_distribution"] = distribution - _meta["quality_distribution_stored"] = True _meta["exposure_pipeline_version"] = 3 _meta["exposure_render_mode"] = render_mode_meta _meta["exposure_quality"] = exposure_quality - # Record the full set of settings used for THIS analysis - # run so users can later see which parameters produced - # the cached results (detection thresholds, rating - # profile, detector variant, etc.). This is a snapshot - # — re-running analysis overwrites it. _meta["analyzed_utc"] = _dt.now(_tz.utc).strftime("%Y-%m-%dT%H:%M:%S.%fZ") _meta["kestrel_version"] = VERSION _meta["analysis_settings"] = { "detector_name": str(getattr(self, "detector_name", "") or ""), "use_gpu": bool(getattr(self, "use_gpu", False)), "wildlife_enabled": bool(wildlife_enabled), + "species_detection_enabled": bool(species_detection_enabled), "detection_threshold": float(detection_threshold), "scene_time_threshold": float(scene_time_threshold), "mask_threshold": float(mask_threshold), @@ -1311,7 +1468,7 @@ def process_subject_items(indices): except Exception as _meta_e: log_warning( self._log_path, - f"Failed to write quality distribution to metadata: {_meta_e}", + f"Failed to write analysis metadata: {_meta_e}", stage="post_analysis_normalization", ) diff --git a/analyzer/kestrel_analyzer/ratings.py b/analyzer/kestrel_analyzer/ratings.py index d7b49cc6..7bdefc90 100644 --- a/analyzer/kestrel_analyzer/ratings.py +++ b/analyzer/kestrel_analyzer/ratings.py @@ -44,24 +44,6 @@ def quality_to_rating(q: float, thresholds: dict = None) -> int: return 1 -def compute_quality_distribution(quality_scores) -> list: - """Compute distribution of quality scores in 100 buckets of 0.01 width. - - Only includes scores >= 0 (detected subjects; quality == -1 means no detection). - Returns a list of 100 ints where index i = count of scores in [i*0.01, (i+1)*0.01). - """ - buckets = [0] * 100 - for q in quality_scores: - try: - q_f = float(q) - except (TypeError, ValueError): - continue - if q_f >= 0: - idx = min(int(q_f * 100), 99) - buckets[idx] += 1 - return buckets - - def get_image_display_rating( filename: str, quality: float, diff --git a/analyzer/kestrel_analyzer/validation.py b/analyzer/kestrel_analyzer/validation.py new file mode 100644 index 00000000..d1dac209 --- /dev/null +++ b/analyzer/kestrel_analyzer/validation.py @@ -0,0 +1,386 @@ +"""End-to-end validation harness for Project Kestrel. + +Runs 9 sequential checks against the current environment (source-mode or +frozen-binary) and reports PASS/FAIL for each. Used by ``cli.py --validate`` +to prove a build is shippable before it leaves CI. + +This module MUST NOT import ``pytest``, ``unittest``, or any test framework — +it is bundled into the PyInstaller binary and needs to stay dependency-light. +""" + +from __future__ import annotations + +import dataclasses +import json +import os +import shutil +import sys +import tempfile +import traceback +import uuid +from contextlib import contextmanager +from datetime import datetime, timezone +from pathlib import Path +from typing import Callable, Optional + + +@dataclasses.dataclass +class _Ctx: + """Per-run state shared between checks.""" + images_dir: Optional[Path] = None + scratch_dir: Optional[Path] = None + sample_images: list[Path] = dataclasses.field(default_factory=list) + + +def _utc_iso() -> str: + return datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ") + + +def _open_tee_log() -> Optional[object]: + try: + try: + from kestrel_analyzer.logging_utils import get_log_path + except ImportError: + from analyzer.kestrel_analyzer.logging_utils import get_log_path # type: ignore + base = get_log_path(None) + # get_log_path returns a file path inside a log directory; tee log + # alongside it. + log_dir = os.path.dirname(base) if base else None + if not log_dir: + return None + os.makedirs(log_dir, exist_ok=True) + tee_path = os.path.join(log_dir, f"kestrel_validate_{_utc_iso()}.log") + return open(tee_path, "w", encoding="utf-8") + except Exception: + return None + + +def _emit(line: str, tee=None) -> None: + print(line, flush=True) + if tee is not None: + try: + tee.write(line + "\n") + tee.flush() + except Exception: + pass + + +def run_validation(images_dir: Optional[str], output_path: Optional[str]) -> int: + """Run all 9 checks, write a structured JSON report (when output_path is + given), tee human-readable PASS/FAIL lines to stdout and a log file. + + Returns the process exit code: 0 if every check passed, 1 otherwise. + """ + tee = _open_tee_log() + _emit(f"=== Kestrel validation starting at {datetime.now(timezone.utc).isoformat()} ===", tee) + + ctx = _Ctx( + images_dir=Path(images_dir).resolve() if images_dir else None, + ) + + # Prepare a scratch directory for any check that needs to write files + # (pipeline, XMP). Cleaned up at the end. + scratch_root = Path(tempfile.mkdtemp(prefix="kestrel_validate_")) + ctx.scratch_dir = scratch_root + + checks: list[tuple[str, Callable[[_Ctx], tuple[bool, str]]]] = [ + ("frozen_app_flag", _check_frozen_app_flag), + ("model_loading", _check_model_loading), + ("settings_roundtrip", _check_settings_roundtrip), + ("folder_inspection", _check_folder_inspection), + ("exif_read", _check_exif_read), + ("pipeline_execution", _check_pipeline_execution), + ("database_read", _check_database_read), + ("xmp_write", _check_xmp_write), + ("exposure_check", _check_exposure_column), + ] + + results: list[dict] = [] + for name, fn in checks: + try: + ok, detail = fn(ctx) + except Exception as exc: + tb = traceback.format_exc(limit=4) + ok = False + detail = f"{type(exc).__name__}: {exc}\n{tb}" + verdict = "PASS" if ok else "FAIL" + _emit(f"{verdict} {name}: {detail}".rstrip(), tee) + results.append({"name": name, "ok": bool(ok), "detail": str(detail)}) + + all_ok = all(r["ok"] for r in results) + _emit(f"=== Validation {'PASSED' if all_ok else 'FAILED'} — {sum(r['ok'] for r in results)}/{len(results)} checks OK ===", tee) + + if output_path: + try: + with open(output_path, "w", encoding="utf-8") as f: + json.dump( + { + "ok": all_ok, + "checks": results, + "timestamp": datetime.now(timezone.utc).isoformat(), + "frozen": bool(getattr(sys, "frozen", False)), + }, + f, + indent=2, + ) + except Exception as exc: + _emit(f"WARN: failed to write --validate-output JSON: {exc}", tee) + + try: + shutil.rmtree(scratch_root, ignore_errors=True) + except Exception: + pass + if tee is not None: + try: + tee.close() + except Exception: + pass + + return 0 if all_ok else 1 + + +# --- Individual checks -------------------------------------------------------- + +def _check_frozen_app_flag(ctx: _Ctx) -> tuple[bool, str]: + """is_frozen_app() returns a dict with a bool 'frozen' key.""" + try: + from api_bridge import Api + except ImportError: + from analyzer.api_bridge import Api # type: ignore + result = Api().is_frozen_app() + if not isinstance(result, dict): + return False, f"expected dict, got {type(result).__name__}" + if "frozen" not in result: + return False, f"missing 'frozen' key: {result!r}" + if not isinstance(result["frozen"], bool): + return False, f"'frozen' is {type(result['frozen']).__name__}, expected bool" + return True, f"frozen={result['frozen']}" + + +def _check_model_loading(ctx: _Ctx) -> tuple[bool, str]: + """All 3 ONNX model wrappers instantiate without raising.""" + try: + from kestrel_analyzer.ml.speciesnet_sam_hq import SpeciesNetSAMHQWrapper + from kestrel_analyzer.ml.bird_species import BirdSpeciesClassifier + from kestrel_analyzer.ml.quality import QualityClassifier + from kestrel_analyzer.config import ( + MODELS_DIR, + QUALITY_NORMALIZATION_DATA_PATH, + QUALITYCLASSIFIER_PATH, + SPECIESCLASSIFIER_LABELS, + SPECIESCLASSIFIER_PATH, + ) + except ImportError: + from analyzer.kestrel_analyzer.ml.speciesnet_sam_hq import SpeciesNetSAMHQWrapper # type: ignore + from analyzer.kestrel_analyzer.ml.bird_species import BirdSpeciesClassifier # type: ignore + from analyzer.kestrel_analyzer.ml.quality import QualityClassifier # type: ignore + from analyzer.kestrel_analyzer.config import ( # type: ignore + MODELS_DIR, + QUALITY_NORMALIZATION_DATA_PATH, + QUALITYCLASSIFIER_PATH, + SPECIESCLASSIFIER_LABELS, + SPECIESCLASSIFIER_PATH, + ) + wrapper = SpeciesNetSAMHQWrapper(use_gpu=False) + wrapper.ensure_loaded() + # Reuse the same provider coordinator so we don't double-register sessions. + coord = wrapper.coord + BirdSpeciesClassifier( + str(SPECIESCLASSIFIER_PATH), + str(SPECIESCLASSIFIER_LABELS), + coord, + models_dir=str(MODELS_DIR), + ) + QualityClassifier( + str(QUALITYCLASSIFIER_PATH), + normalization_data_path=str(QUALITY_NORMALIZATION_DATA_PATH) + if Path(QUALITY_NORMALIZATION_DATA_PATH).is_file() else None, + coord=coord, + ) + return True, "SpeciesNet+SAM-HQ+bird+quality loaded" + + +_SENTINEL_KEY = "_validate_sentinel" + + +def _check_settings_roundtrip(ctx: _Ctx) -> tuple[bool, str]: + """Write a sentinel key, reload, assert equal, then restore original.""" + try: + from settings_utils import load_persisted_settings, save_persisted_settings + except ImportError: + from analyzer.settings_utils import load_persisted_settings, save_persisted_settings # type: ignore + original = load_persisted_settings() or {} + had_sentinel = _SENTINEL_KEY in original + original_sentinel = original.get(_SENTINEL_KEY) + sentinel_value = f"probe-{uuid.uuid4()}" + try: + mutated = dict(original) + mutated[_SENTINEL_KEY] = sentinel_value + save_persisted_settings(mutated) + reloaded = load_persisted_settings() or {} + if reloaded.get(_SENTINEL_KEY) != sentinel_value: + return False, ( + f"sentinel not preserved: wrote {sentinel_value!r}, " + f"got {reloaded.get(_SENTINEL_KEY)!r} — likely dropped by sanitizer" + ) + return True, "roundtrip preserved sentinel" + finally: + # Always restore: leak-proof even if assertions above failed. + restored = dict(load_persisted_settings() or {}) + if had_sentinel: + restored[_SENTINEL_KEY] = original_sentinel + else: + restored.pop(_SENTINEL_KEY, None) + try: + save_persisted_settings(restored) + except Exception: + pass + + +def _check_folder_inspection(ctx: _Ctx) -> tuple[bool, str]: + """inspect_folder() returns total >= 2 on the validate images dir.""" + if ctx.images_dir is None or not ctx.images_dir.is_dir(): + return False, f"--validate-images dir not found: {ctx.images_dir}" + try: + from folder_inspector import inspect_folder + except ImportError: + from analyzer.folder_inspector import inspect_folder # type: ignore + result = inspect_folder(str(ctx.images_dir)) + total = int(result.get("total", 0)) + if total < 2: + return False, f"total={total} (need >=2 images)" + # Cache sample image paths for downstream checks + try: + from kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS + except ImportError: + from analyzer.kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS # type: ignore + all_exts = set(RAW_EXTENSIONS) | set(JPEG_EXTENSIONS) + files = sorted( + f for f in ctx.images_dir.iterdir() + if f.is_file() and f.suffix.lower() in all_exts + ) + ctx.sample_images = files[:2] + return True, f"total={total}, has_kestrel={result.get('has_kestrel', False)}" + + +def _check_exif_read(ctx: _Ctx) -> tuple[bool, str]: + """get_capture_time() on first sample image returns a datetime.""" + if not ctx.sample_images: + return False, "no sample images cached (folder_inspection must run first)" + try: + from kestrel_analyzer.raw_exif import get_capture_time + except ImportError: + from analyzer.kestrel_analyzer.raw_exif import get_capture_time # type: ignore + sample = ctx.sample_images[0] + ts = get_capture_time(str(sample)) + if not isinstance(ts, datetime): + return False, f"{sample.name}: expected datetime, got {type(ts).__name__}" + return True, f"{sample.name} -> {ts.isoformat()}" + + +def _check_pipeline_execution(ctx: _Ctx) -> tuple[bool, str]: + """Copy 2 sample images into scratch, run AnalysisPipeline.process_folder().""" + if not ctx.sample_images: + return False, "no sample images cached" + if ctx.scratch_dir is None: + return False, "no scratch dir" + work = ctx.scratch_dir / "pipeline" + work.mkdir(exist_ok=True) + for src in ctx.sample_images[:2]: + shutil.copy2(src, work / src.name) + try: + from kestrel_analyzer.pipeline import AnalysisPipeline + from kestrel_analyzer.config import DEFAULT_DETECTOR_NAME + except ImportError: + from analyzer.kestrel_analyzer.pipeline import AnalysisPipeline # type: ignore + from analyzer.kestrel_analyzer.config import DEFAULT_DETECTOR_NAME # type: ignore + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + pipeline.process_folder( + folder=str(work), + analyzer_name="cli_validate", + wildlife_enabled=True, + species_detection_enabled=True, + detection_threshold=0.25, + scene_time_threshold=60.0, + max_bird_crops=5, + parallel_prefetch=1, + ) + kestrel_dir = work / ".kestrel" + if not kestrel_dir.is_dir(): + return False, f"pipeline did not create {kestrel_dir}" + csv_path = kestrel_dir / "kestrel_database.csv" + if not csv_path.is_file(): + return False, "kestrel_database.csv not created" + return True, f"pipeline produced {kestrel_dir.name}/kestrel_database.csv" + + +def _check_database_read(ctx: _Ctx) -> tuple[bool, str]: + """load_database() returns a frame whose columns are a superset of BASE_COLUMNS.""" + if ctx.scratch_dir is None: + return False, "no scratch dir" + kestrel_dir = ctx.scratch_dir / "pipeline" / ".kestrel" + if not kestrel_dir.is_dir(): + return False, f"missing .kestrel dir: {kestrel_dir}" + try: + from kestrel_analyzer.database import BASE_COLUMNS, load_database + except ImportError: + from analyzer.kestrel_analyzer.database import BASE_COLUMNS, load_database # type: ignore + db, _ = load_database(str(kestrel_dir), "cli_validate") + missing = [c for c in BASE_COLUMNS if c not in db.columns] + if missing: + return False, f"missing columns: {missing}" + if len(db) < 1: + return False, f"database empty: {len(db)} rows" + return True, f"{len(db)} rows, {len(db.columns)} cols" + + +def _check_xmp_write(ctx: _Ctx) -> tuple[bool, str]: + """write_xmp_metadata() produces .xmp sidecars for each row.""" + if ctx.scratch_dir is None: + return False, "no scratch dir" + work = ctx.scratch_dir / "pipeline" + kestrel_dir = work / ".kestrel" + if not kestrel_dir.is_dir(): + return False, f"pipeline output missing: {kestrel_dir}" + try: + from metadata_writer import write_xmp_metadata + from kestrel_analyzer.database import load_database + except ImportError: + from analyzer.metadata_writer import write_xmp_metadata # type: ignore + from analyzer.kestrel_analyzer.database import load_database # type: ignore + db, _ = load_database(str(kestrel_dir), "cli_validate") + image_data = [ + { + "filename": str(row["filename"]), + "rating": 3, + "culled": "accept", + "culled_origin": "manual", + "species": str(row.get("species", "")), + "family": str(row.get("family", "")), + "quality": float(row.get("quality", 0.0)) if row.get("quality") is not None else 0.0, + } + for _, row in db.iterrows() + ] + write_xmp_metadata(str(work), image_data, overwrite_external=True, use_auto_labels=False) + sidecars = list(work.glob("*.xmp")) + if len(sidecars) < len(image_data): + return False, f"wrote {len(sidecars)} sidecars for {len(image_data)} rows" + return True, f"{len(sidecars)} XMP sidecars written" + + +def _check_exposure_column(ctx: _Ctx) -> tuple[bool, str]: + """exposure_correction column is non-null for every pipeline row.""" + if ctx.scratch_dir is None: + return False, "no scratch dir" + kestrel_dir = ctx.scratch_dir / "pipeline" / ".kestrel" + try: + from kestrel_analyzer.database import load_database + except ImportError: + from analyzer.kestrel_analyzer.database import load_database # type: ignore + db, _ = load_database(str(kestrel_dir), "cli_validate") + if "exposure_correction" not in db.columns: + return False, "missing exposure_correction column" + if not db["exposure_correction"].notna().all(): + nans = int(db["exposure_correction"].isna().sum()) + return False, f"{nans}/{len(db)} rows have null exposure_correction" + return True, f"all {len(db)} rows have a value" diff --git a/analyzer/kestrel_telemetry.py b/analyzer/kestrel_telemetry.py index 14574f5e..f63c94a2 100644 --- a/analyzer/kestrel_telemetry.py +++ b/analyzer/kestrel_telemetry.py @@ -32,11 +32,21 @@ import ssl import certifi # ensure we have a CA bundle for HTTPS requests, even in frozen/packaged environments + +try: + from build_attestation import auth_headers as _build_auth_headers +except Exception: + # Failsafe: if the module can't load for any reason, fall back to legacy headers only. + def _build_auth_headers(): + return {} # --------------------------------------------------------------------------- # Configuration — the shared secret and endpoint URL # --------------------------------------------------------------------------- KESTREL_API_URL = "https://api.projectkestrel.org" # production endpoint #KESTREL_API_URL = "http://127.0.0.1:8787" # local testing endpoint +# Legacy shared secret. Official builds override authentication via HMAC headers +# from build_attestation.auth_headers(); this constant remains as the fallback +# tier for source/dev builds and for pre-attestation installed binaries. KESTREL_SHARED_SECRET = "kestrel_secret_dev_shared" # basic abuse-prevention _TIMEOUT_SECONDS = 10 @@ -117,6 +127,21 @@ def _get_ssl_context(): ctx = ssl.create_default_context(cafile=certifi.where()) return ctx +def _warn(msg: str) -> None: + """Lazy-import warn from settings_utils to avoid a circular import. + + settings_utils imports kestrel_telemetry at module load; we can't import + back at the top level. Resolving the symbol at call time is fine because + telemetry posts only happen long after module init. + """ + try: + from settings_utils import warn as _w + _w(msg) + except Exception: + # Failsafe: telemetry must never raise. + print(msg, file=sys.stderr, flush=True) + + def _post_json(endpoint: str, payload: dict) -> None: """POST JSON to the Cloudflare Worker (fire-and-forget, failsafe).""" if urllib is None: @@ -124,26 +149,33 @@ def _post_json(endpoint: str, payload: dict) -> None: url = f"{KESTREL_API_URL}{endpoint}" try: data = json.dumps(payload).encode('utf-8') + headers = { + 'Content-Type': 'application/json', + 'Accept': 'application/json', + 'User-Agent': 'Mozilla/5.0 (KestrelTelemetry/1.0)', + } + # Official builds carry HMAC attestation headers; everyone else falls back + # to the legacy shared-secret header. The worker accepts both. + attestation = _build_auth_headers() + if attestation: + headers.update(attestation) + else: + headers['X-Kestrel-Key'] = KESTREL_SHARED_SECRET req = urllib.request.Request( url, data=data, - headers={ - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'X-Kestrel-Key': KESTREL_SHARED_SECRET, - 'User-Agent': 'Mozilla/5.0 (KestrelTelemetry/1.0)', - }, + headers=headers, method='POST', ) with urllib.request.urlopen(req, timeout=_TIMEOUT_SECONDS, context=_get_ssl_context()): pass except urllib.error.HTTPError as e: body = e.read().decode('utf-8', errors='replace') if hasattr(e, 'read') else '' - print(f'[telemetry] HTTP {e.code} from {url}: {body[:300]}', flush=True) + _warn(f'[telemetry] HTTP {e.code} from {url}: {body[:300]}') except urllib.error.URLError as e: - print(f'[telemetry] URLError posting to {url}: {e.reason}', flush=True) + _warn(f'[telemetry] URLError posting to {url}: {e.reason}') except Exception as e: - print(f'[telemetry] Error posting to {url}: {e}', flush=True) + _warn(f'[telemetry] Error posting to {url}: {e}') def _post_json_async(endpoint: str, payload: dict) -> None: @@ -225,6 +257,7 @@ def send_crash_report( session_analytics: Optional[dict] = None, machine_id: str = '', version: str = '', + exit_reason: str = 'crash', ) -> None: """Send a crash report to the Cloudflare Worker (async, failsafe). @@ -240,6 +273,13 @@ def send_crash_report( Any analytics data collected so far in this session. machine_id, version : str Machine identifier and app version. + exit_reason : str + How the previous session ended. ``'crash'`` (default) for true + unhandled exceptions; ``'unknown'`` when the user opted in for an + ambiguous exit (SIGKILL, power loss); ``'os_shutdown'`` defensive + only — the recovery dialog filters these client-side, but the + server should record any that slip through so they can be excluded + from crash-rate dashboards. """ if not is_frozen(): return @@ -260,6 +300,7 @@ def send_crash_report( 'machine_id': machine_id, 'version': version or _read_version(), 'os': _get_os_info(), + 'exit_reason': exit_reason, } _post_json_async('/api/crash', payload) except Exception: @@ -546,16 +587,8 @@ def collect_folder_stats(item_path: str, files_this_session: int, total_files: i dict with keys: file_sizes_kb, file_formats """ try: - # Import known extensions - try: - from kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS - except ImportError: - try: - from analyzer.kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS - except ImportError: - RAW_EXTENSIONS = {'.cr2', '.cr3', '.nef', '.arw', '.dng', '.orf', '.rw2', '.raf'} - JPEG_EXTENSIONS = {'.jpg', '.jpeg', '.png', '.tiff', '.tif', '.bmp', '.webp'} - + from kestrel_analyzer.config import RAW_EXTENSIONS, JPEG_EXTENSIONS + file_sizes_kb: List[float] = [] file_formats: Dict[str, int] = {} # Ensure we can combine lists or sets without raising a TypeError diff --git a/analyzer/main.py b/analyzer/main.py deleted file mode 100644 index 75684c4a..00000000 --- a/analyzer/main.py +++ /dev/null @@ -1,63 +0,0 @@ -import sys - -def _create_splash(app): - splash = QWidget() - splash.setWindowTitle("Kestrel Analyzer") - splash.setFixedSize(420, 160) - layout = QVBoxLayout(splash) - title_label = QLabel("Project Kestrel is Loading…", splash) - title_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - title_label.setObjectName("splashTitle") - status_label = QLabel("Starting…", splash) - status_label.setAlignment(Qt.AlignmentFlag.AlignCenter) - status_label.setObjectName("splashStatus") - layout.addStretch(1) - layout.addWidget(title_label) - layout.addWidget(status_label) - layout.addStretch(1) - splash.setLayout(layout) - splash.show() - app.processEvents() - return splash - -def _set_splash_text(app, splash, text: str) -> None: - label = splash.findChild(QLabel, "splashStatus") - if label: - label.setText(text) - app.processEvents() - - -def _run_cli() -> None: - from cli import main - main() - -if __name__ == "__main__": - if "--cli" in sys.argv: - sys.argv = [arg for arg in sys.argv if arg != "--cli"] - _run_cli() - raise SystemExit(0) - - from PyQt6.QtCore import Qt - from PyQt6.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget - - app = QApplication(sys.argv) - splash = _create_splash(app) - - _set_splash_text(app, splash, "Loading ONNX Runtime…") - import onnxruntime as ort - - _set_splash_text(app, splash, "Starting UI…") - - from kestrel_analyzer.logging_utils import get_log_path, log_event - from gui_app import main - - log_path = get_log_path(None) - log_event( - log_path, - { - "level": "info", - "event": "gui_start", - }, - ) - splash.close() - main(app) \ No newline at end of file diff --git a/analyzer/metadata_writer.py b/analyzer/metadata_writer.py index 8ad6e75d..b9a4b737 100644 --- a/analyzer/metadata_writer.py +++ b/analyzer/metadata_writer.py @@ -7,7 +7,6 @@ """ import os -import sys # XMP namespace URIs _KESTREL_NS = 'http://ns.projectkestrel.app/xmp/1.0/' @@ -91,9 +90,7 @@ def _safe_sidecar_path(root: str, filename: str) -> str | None: } -def log(*args): - """Log message to stderr with [metadata] prefix.""" - print('[metadata]', *args, file=sys.stderr) +from settings_utils import debug, info, warn, error def _xml_escape(text: str) -> str: @@ -335,7 +332,7 @@ def write_xmp_metadata( resolved_image = _safe_sidecar_path(root_path, filename) if resolved_image is None: errors.append(f'{filename}: rejected (unsafe filename)') - log(f'[security] write_xmp_metadata rejected unsafe filename: {filename!r}') + warn(f'[metadata][security] write_xmp_metadata rejected unsafe filename: {filename!r}') continue base, _ext = os.path.splitext(resolved_image) xmp_path = base + '.xmp' @@ -346,10 +343,10 @@ def write_xmp_metadata( if not _is_kestrel_xmp(xmp_path): if not overwrite_external: skipped_conflicts.append(xmp_filename) - log(f'write_xmp: skipping external XMP {xmp_path}') + warn(f'[metadata] write_xmp: skipping external XMP {xmp_path}') continue else: - log(f'write_xmp: overwriting external XMP {xmp_path} (user confirmed)') + info(f'[metadata] write_xmp: overwriting external XMP {xmp_path} (user confirmed)') xmp_content = _build_xmp_packet( rating=rating, @@ -366,12 +363,12 @@ def write_xmp_metadata( f.write(xmp_content) written += 1 - log(f'write_xmp: wrote {xmp_path}') + info(f'[metadata] write_xmp: wrote {xmp_path}') except Exception as entry_err: errors.append(f'{entry.get("filename", "?")}: {entry_err}') - log(f'write_xmp_metadata: written={written}, conflicts={len(skipped_conflicts)}, errors={len(errors)}') + info(f'[metadata] write_xmp_metadata: written={written}, conflicts={len(skipped_conflicts)}, errors={len(errors)}') return { 'success': True, 'written': written, @@ -380,5 +377,5 @@ def write_xmp_metadata( } except Exception as e: - log(f'write_xmp_metadata error: {e}') + error(f'[metadata] write_xmp_metadata error: {e}') return {'success': False, 'error': str(e)} diff --git a/analyzer/models/quality_normalization_data.csv b/analyzer/models/quality_normalization_data.csv index dcd070db..4faf041a 100644 --- a/analyzer/models/quality_normalization_data.csv +++ b/analyzer/models/quality_normalization_data.csv @@ -1,102 +1,102 @@ percentile,quality -0,0.014146553352 -1,0.016835931465 -2,0.018683972657 -3,0.019710990116 -4,0.020765883774 -5,0.021608404070 -6,0.022527581379 -7,0.023167410120 -8,0.024351176992 -9,0.025415541008 -10,0.026834568009 -11,0.028666973263 -12,0.029863043278 -13,0.031533200294 -14,0.033036675453 -15,0.034212207794 -16,0.035460978299 -17,0.036604837328 -18,0.037709383219 -19,0.039249620438 -20,0.040569347143 -21,0.042164324224 -22,0.043329351395 -23,0.045347056836 -24,0.047391953766 -25,0.051088429987 -26,0.056764824837 -27,0.062119492739 -28,0.072385837138 -29,0.079956715703 -30,0.094837519526 -31,0.108040550947 -32,0.125098208785 -33,0.136026259065 -34,0.148690837026 -35,0.165046682954 -36,0.183083600998 -37,0.197763196826 -38,0.217215290070 -39,0.240586396456 -40,0.267345178127 -41,0.300814327002 -42,0.328879231215 -43,0.364088404179 -44,0.391080843210 -45,0.428411835432 -46,0.454748107195 -47,0.474558990002 -48,0.515919313431 -49,0.549228181839 -50,0.573821604252 -51,0.602085952759 -52,0.630835528374 -53,0.659741866589 -54,0.685425126553 -55,0.709884178638 -56,0.725662260056 -57,0.749728028774 -58,0.762236905098 -59,0.785707600117 -60,0.798933947086 -61,0.810012514591 -62,0.825476324558 -63,0.839172632694 -64,0.854974949360 -65,0.869151890278 -66,0.881828179359 -67,0.895205485821 -68,0.903732569218 -69,0.915135109425 -70,0.923006677628 -71,0.931653079987 -72,0.938036160469 -73,0.942600808144 -74,0.951884107590 -75,0.958318650723 -76,0.963245015144 -77,0.967255518436 -78,0.971891870499 -79,0.975716459751 -80,0.978582382202 -81,0.981751406193 -82,0.984468400478 -83,0.987310862541 -84,0.989304311275 -85,0.991987156868 -86,0.993795549870 -87,0.995132439137 -88,0.996216242313 -89,0.996916141510 -90,0.997724497318 -91,0.998377299309 -92,0.998864665031 -93,0.999235029221 -94,0.999612712860 -95,0.999748301506 -96,0.999891874790 -97,0.999943237305 -98,0.999985985756 -99,0.999997553825 -100,1.000000000000 \ No newline at end of file +0,0.027737438679 +1,0.032857429683 +2,0.034246844649 +3,0.035471231937 +4,0.036283622980 +5,0.037981218100 +6,0.041018229127 +7,0.043926655948 +8,0.045937336683 +9,0.049291025102 +10,0.053414875269 +11,0.056841523051 +12,0.060080168247 +13,0.065499602854 +14,0.068126742840 +15,0.070987363160 +16,0.074365400076 +17,0.077472084463 +18,0.081989609599 +19,0.086135742664 +20,0.093609791994 +21,0.098780037463 +22,0.105616986156 +23,0.113614726067 +24,0.121708967686 +25,0.132794320583 +26,0.151814187169 +27,0.175599464774 +28,0.193531298637 +29,0.214027759731 +30,0.231473398209 +31,0.251281219125 +32,0.273986124992 +33,0.297171789408 +34,0.327247481942 +35,0.350906942785 +36,0.378766674995 +37,0.408821343780 +38,0.435589621067 +39,0.456894564033 +40,0.491868305206 +41,0.519829968214 +42,0.545343159437 +43,0.574093978405 +44,0.594434185028 +45,0.615716385841 +46,0.633169499636 +47,0.649337347746 +48,0.664439716339 +49,0.686683027148 +50,0.703174740076 +51,0.723942928910 +52,0.739553234577 +53,0.758392146826 +54,0.770209161043 +55,0.783459472656 +56,0.795457396507 +57,0.806741027236 +58,0.821818405390 +59,0.832197176814 +60,0.841081643105 +61,0.849984002113 +62,0.861003117561 +63,0.869435722828 +64,0.878877165318 +65,0.885724681616 +66,0.893107297421 +67,0.901059466600 +68,0.905903377533 +69,0.912375172973 +70,0.918449962139 +71,0.922242063284 +72,0.927875535488 +73,0.933014731407 +74,0.938536542654 +75,0.943445980549 +76,0.947595567703 +77,0.952162349224 +78,0.955065021515 +79,0.958044073582 +80,0.961812162399 +81,0.965413692594 +82,0.968587185144 +83,0.971574236751 +84,0.975197582245 +85,0.978455147147 +86,0.981131837368 +87,0.983602536321 +88,0.985994305611 +89,0.987633335590 +90,0.989182704687 +91,0.991077370048 +92,0.992620282173 +93,0.994097169042 +94,0.995773119926 +95,0.996877115965 +96,0.997586145401 +97,0.998398263454 +98,0.999231308699 +99,0.999829362035 +100,0.999999225140 diff --git a/analyzer/models/speciesnet/mdv1000-cedar.onnx b/analyzer/models/speciesnet/mdv1000-cedar.onnx new file mode 100644 index 00000000..d12ae166 --- /dev/null +++ b/analyzer/models/speciesnet/mdv1000-cedar.onnx @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:b6a89b1e2d398f218298de6453d1dfa4ec62a7f71cc987d19fdc69cc227cd3e1 +size 101984238 diff --git a/analyzer/models/speciesnet/mdv6-apa-rtdetr-e-summary.json b/analyzer/models/speciesnet/mdv6-apa-rtdetr-e-summary.json deleted file mode 100644 index 93defd2b..00000000 --- a/analyzer/models/speciesnet/mdv6-apa-rtdetr-e-summary.json +++ /dev/null @@ -1,94 +0,0 @@ -{ - "model": "MDV6-apa-rtdetr-e", - "test_image_source": "/home/runner/work/CameraTraps/CameraTraps/IMG-20260410-WA0011.jpg", - "onnx_path": "/home/runner/work/CameraTraps/CameraTraps/artifacts/MDV6-apa-rtdetr-e.onnx", - "onnx_size_bytes": 3938290, - "outputs": [ - { - "name": "labels", - "shape": [ - 1, - 300 - ] - }, - { - "name": "boxes", - "shape": [ - 1, - 300, - 4 - ] - }, - { - "name": "scores", - "shape": [ - 1, - 300 - ] - } - ], - "comparison": { - "rtol": 0.001, - "atol": 0.01, - "num_outputs": 3, - "per_output": [ - { - "output_index": 0, - "shape_torch": [ - 1, - 300 - ], - "shape_ort": [ - 1, - 300 - ], - "dtype_torch": "int64", - "dtype_ort": "int64", - "exact_match": true, - "allclose": true, - "max_abs_diff": 0.0, - "mean_abs_diff": 0.0, - "passed": true - }, - { - "output_index": 1, - "shape_torch": [ - 1, - 300, - 4 - ], - "shape_ort": [ - 1, - 300, - 4 - ], - "dtype_torch": "float32", - "dtype_ort": "float32", - "exact_match": false, - "allclose": false, - "max_abs_diff": 411.30914306640625, - "mean_abs_diff": 1.8399985501977305, - "passed": false - }, - { - "output_index": 2, - "shape_torch": [ - 1, - 300 - ], - "shape_ort": [ - 1, - 300 - ], - "dtype_torch": "float32", - "dtype_ort": "float32", - "exact_match": false, - "allclose": true, - "max_abs_diff": 9.98377799987793e-07, - "mean_abs_diff": 7.339442769686382e-08, - "passed": true - } - ], - "validation_passed": false - } -} \ No newline at end of file diff --git a/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx b/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx deleted file mode 100644 index dbcb012f..00000000 --- a/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:6eec7e070c6a61cea7fdff6c66195b6d12fe7e9ef4a79789af47b348b3962b7d -size 2395837 diff --git a/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx.data b/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx.data deleted file mode 100644 index cf9af3e7..00000000 --- a/analyzer/models/speciesnet/mdv6-mit-yolov9-c.onnx.data +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:41278098f371900abf6f1555afbd5881167c2258fe34148466a328d46983f37c -size 28901376 diff --git a/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx b/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx deleted file mode 100644 index f335e1fe..00000000 --- a/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:c0f429e1c4f24265f37056043987a8a28836f34889e20d16e2227f6a994eaf73 -size 1631719 diff --git a/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx.data b/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx.data deleted file mode 100644 index 5562dd39..00000000 --- a/analyzer/models/speciesnet/mdv6-mit-yolov9-e.onnx.data +++ /dev/null @@ -1,3 +0,0 @@ -version https://git-lfs.github.com/spec/v1 -oid sha256:141d528dc7f6c83f8426a6f52ecc35d1ce80ff0c537be8395fcaecf5884b4f76 -size 101908480 diff --git a/analyzer/models/speciesnet/sam_hq_vit_tiny_decoder.onnx b/analyzer/models/speciesnet/sam_hq_vit_tiny_decoder.onnx index 5e6366f3..b9104cbc 100644 --- a/analyzer/models/speciesnet/sam_hq_vit_tiny_decoder.onnx +++ b/analyzer/models/speciesnet/sam_hq_vit_tiny_decoder.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e19a3c23a351f4fade709a05a7cf1d60576e764f68b464baf1e8dae939c31ba2 -size 18312841 +oid sha256:c7a441c97c89f5c97a06fd56387e0df4068ed7b654dd809ba3b27ec9352e77a8 +size 18325380 diff --git a/analyzer/models/speciesnet/sam_hq_vit_tiny_encoder.onnx b/analyzer/models/speciesnet/sam_hq_vit_tiny_encoder.onnx index 7c34593e..29016e03 100644 --- a/analyzer/models/speciesnet/sam_hq_vit_tiny_encoder.onnx +++ b/analyzer/models/speciesnet/sam_hq_vit_tiny_encoder.onnx @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b628f8e89ebb40f3dcc92c3205796fa27e5fcbff85fcdea3e91597588892a1a0 -size 28200842 +oid sha256:700fc622dff0edf8495c5b730e4b74b13fc4d95e58212a49422e7ff73a036b87 +size 28203106 diff --git a/analyzer/queue_manager.py b/analyzer/queue_manager.py index 375279db..96e69530 100644 --- a/analyzer/queue_manager.py +++ b/analyzer/queue_manager.py @@ -12,7 +12,7 @@ import time as _time_mod from datetime import datetime -from settings_utils import load_persisted_settings, save_persisted_settings, log +from settings_utils import load_persisted_settings, save_persisted_settings, debug, info, warn, error, log # Telemetry — failsafe import (never blocks startup) try: @@ -32,7 +32,7 @@ _pipeline_import_error = '' _AnalysisPipeline = None # populated lazily on first use _DEFAULT_DETECTOR_NAME = 'mdv5a' -_ALLOWED_DETECTOR_NAMES = {'mdv5a', 'mdv6-e'} +_ALLOWED_DETECTOR_NAMES = {'mdv5a', 'mdv1000-cedar'} def _coerce_detector_name(value) -> str: @@ -70,13 +70,13 @@ def _ensure_pipeline_path() -> bool: def _get_pipeline_class(): """Import and cache AnalysisPipeline on first call (deferred ML import).""" global _AnalysisPipeline, _PIPELINE_AVAILABLE, _pipeline_import_error - log("_get_pipeline_class() called, available:", _PIPELINE_AVAILABLE) + debug("[queue] _get_pipeline_class() called, available:", _PIPELINE_AVAILABLE) if _AnalysisPipeline is not None: return _AnalysisPipeline try: - log("Importing AnalysisPipeline from kestrel_analyzer.pipeline...") + info("[queue] Importing AnalysisPipeline from kestrel_analyzer.pipeline...") from kestrel_analyzer.pipeline import AnalysisPipeline # type: ignore # noqa: PLC0415 - log("AnalysisPipeline imported successfully.") + info("[queue] AnalysisPipeline imported successfully.") _AnalysisPipeline = AnalysisPipeline _PIPELINE_AVAILABLE = True return _AnalysisPipeline @@ -176,12 +176,14 @@ def __init__(self): self._pipeline = None self._use_gpu = True self._wildlife_enabled = True + self._species_detection_enabled = True self._detection_threshold = 0.25 self._scene_time_threshold = 1.0 self._mask_threshold = 0.5 self._max_bird_crops = 10 self._parallel_prefetch = 3 self._detector_name = _DEFAULT_DETECTOR_NAME + self._retry_errored = False def _collect_restore_paths_locked(self) -> list: restore_statuses = {'pending', 'running', 'cancelled'} @@ -206,6 +208,7 @@ def _build_recovery_state_locked(self) -> dict: 'options': { 'use_gpu': bool(self._use_gpu), 'wildlife_enabled': bool(self._wildlife_enabled), + 'species_detection_enabled': bool(self._species_detection_enabled), 'detector_name': str(self._detector_name), 'detection_threshold': float(self._detection_threshold), 'scene_time_threshold': float(self._scene_time_threshold), @@ -300,6 +303,7 @@ def _safe_int(val, default, min_value=1, max_value=20): restore_paths, use_gpu=bool(options.get('use_gpu', True)), wildlife_enabled=bool(options.get('wildlife_enabled', True)), + species_detection_enabled=bool(options.get('species_detection_enabled', True)), detector_name=_coerce_detector_name(options.get('detector_name', _DEFAULT_DETECTOR_NAME)), detection_threshold=_safe_float(options.get('detection_threshold', 0.25), 0.25), scene_time_threshold=_safe_float(options.get('scene_time_threshold', 1.0), 1.0), @@ -349,12 +353,14 @@ def enqueue( paths: list, use_gpu: bool = True, wildlife_enabled: bool = True, + species_detection_enabled: bool = True, detector_name: str = _DEFAULT_DETECTOR_NAME, detection_threshold: float = 0.25, scene_time_threshold: float = 1.0, mask_threshold: float = 0.5, max_bird_crops: int = 10, parallel_prefetch: int = 3, + retry_errored: bool = False, ) -> dict: if not _PIPELINE_AVAILABLE: return {'success': False, 'error': f'Analyzer unavailable: {_pipeline_import_error}'} @@ -395,6 +401,7 @@ def enqueue( self._pause_event.set() self._use_gpu = use_gpu self._wildlife_enabled = wildlife_enabled + self._species_detection_enabled = bool(species_detection_enabled) self._detection_threshold = float(detection_threshold) self._scene_time_threshold = float(scene_time_threshold) self._mask_threshold = float(mask_threshold) @@ -409,6 +416,7 @@ def enqueue( except (TypeError, ValueError): parallel_prefetch_num = 3 self._parallel_prefetch = max(1, min(5, parallel_prefetch_num)) + self._retry_errored = bool(retry_errored) self._thread = threading.Thread(target=self._run, daemon=True, name='kestrel-queue') self._thread.start() self._persist_recovery_state() @@ -505,7 +513,7 @@ def _run(self): if it.status in ('pending', 'running'): it.status = 'error' it.error = f'Pipeline unavailable: {_pipeline_import_error}' - log('[queue] Pipeline unavailable, aborting:', _pipeline_import_error) + error('[queue] Pipeline unavailable, aborting:', _pipeline_import_error) self._persist_recovery_state() return self._pipeline = cls(use_gpu=self._use_gpu, detector_name=self._detector_name) @@ -546,7 +554,7 @@ def _on_progress(processed, total, _it=item): def _on_status(msg, _it=item): with self._lock: _it.current_status_msg = msg - log(f'[queue:{_it.name}]', msg) + debug(f'[queue:{_it.name}]', msg) def _on_thumbnail(data, _it=item): with self._lock: @@ -642,11 +650,13 @@ def _on_species(data, _it=item): }, analyzer_name='visualizer-queue', wildlife_enabled=self._wildlife_enabled, + species_detection_enabled=self._species_detection_enabled, detection_threshold=self._detection_threshold, scene_time_threshold=self._scene_time_threshold, mask_threshold=self._mask_threshold, max_bird_crops=self._max_bird_crops, parallel_prefetch=self._parallel_prefetch, + retry_errored=self._retry_errored, ) with self._lock: if self._cancel_event.is_set(): @@ -660,7 +670,7 @@ def _on_species(data, _it=item): self._persist_recovery_state() self._send_folder_analytics(item) except Exception as exc: - log(f'[queue] Error processing {item.path!r}:', exc) + error(f'[queue] Error processing {item.path!r}:', exc) with self._lock: item.status = 'error' item.end_time = _time_mod.time() @@ -668,7 +678,7 @@ def _on_species(data, _it=item): self._persist_recovery_state() self._send_folder_analytics(item) - log('[queue] Run thread finished.') + info('[queue] Run thread finished.') self._persist_recovery_state() def _send_folder_analytics(self, item): diff --git a/analyzer/runtime_hook.py b/analyzer/runtime_hook.py index 9fb4ef8f..ebe55064 100644 --- a/analyzer/runtime_hook.py +++ b/analyzer/runtime_hook.py @@ -4,11 +4,22 @@ import glob +# This file runs before the rest of the codebase is importable (it IS the +# PyInstaller runtime hook). We can't import the leveled logger from +# ``settings_utils`` here. Instead, gate the debug output behind the same +# env var the leveled logger uses, so users see this tree dump only when +# they've opted in for verbose diagnostics. +_DEBUG_BUNDLE = os.environ.get('KESTREL_LOG_LEVEL', '').upper() == 'DEBUG' + + def _debug(msg: str) -> None: - print(f"[runtime_hook] {msg}") + if _DEBUG_BUNDLE: + print(f"[runtime_hook] {msg}") def _dump_tree(root: str, max_depth: int = 2) -> None: + if not _DEBUG_BUNDLE: + return if not os.path.isdir(root): _debug(f"MEIPASS not a directory: {root}") return diff --git a/analyzer/settings_utils.py b/analyzer/settings_utils.py index cefab0d3..396f00e4 100644 --- a/analyzer/settings_utils.py +++ b/analyzer/settings_utils.py @@ -45,7 +45,23 @@ _ALLOWED_RATING_PROFILES = {'very_strict', 'strict', 'balanced', 'lenient', 'very_lenient'} _ALLOWED_EXPOSURE_QUALITY = {'lenient', 'balanced', 'aggressive'} _ALLOWED_WILDLIFE_MODEL_MODES = {'fast', 'accurate'} -_ALLOWED_QUEUE_DETECTOR_NAMES = {'mdv5a', 'mdv6-e'} +_ALLOWED_QUEUE_DETECTOR_NAMES = {'mdv5a', 'mdv1000-cedar'} +# Legacy detector names → current names. Applied silently at load to migrate +# stored settings from older builds. UI semantics ("Fast"/"Accurate") are +# preserved, so users never see a change. +_LEGACY_DETECTOR_NAME_MIGRATIONS = {'mdv6-e': 'mdv1000-cedar'} + + +def _migrate_legacy_detector_name(value): + """Remap a stored detector name through ``_LEGACY_DETECTOR_NAME_MIGRATIONS``. + + Non-string or unknown values pass through unchanged so that the downstream + ``_coerce_enum`` allowlist check handles them. + """ + if not isinstance(value, str): + return value + norm = value.strip().lower() + return _LEGACY_DETECTOR_NAME_MIGRATIONS.get(norm, value) _ALLOWED_QUEUE_ITEM_STATUSES = {'pending', 'running', 'done', 'error', 'cancelled'} # Telemetry — failsafe import (never blocks startup) @@ -243,8 +259,9 @@ def _sanitize_queue_recovery_state(value: Any) -> dict | None: state['options'] = { 'use_gpu': _coerce_bool(opts.get('use_gpu', True), default=True), 'wildlife_enabled': _coerce_bool(opts.get('wildlife_enabled', True), default=True), + 'species_detection_enabled': _coerce_bool(opts.get('species_detection_enabled', True), default=True), 'detector_name': _coerce_enum( - opts.get('detector_name', 'mdv5a'), + _migrate_legacy_detector_name(opts.get('detector_name', 'mdv5a')), _ALLOWED_QUEUE_DETECTOR_NAMES, default='mdv5a', ), @@ -341,7 +358,7 @@ def _merge_forward_compatible_keys(out: dict[str, Any], data: dict, emit_log: bo if emit_log and skipped: sample = ', '.join(skipped[:12]) suffix = ' ...' if len(skipped) > 12 else '' - log(f'[settings] Could not preserve {len(skipped)} key(s) (unsupported type): {sample}{suffix}') + warn(f'[settings] Could not preserve {len(skipped)} key(s) (unsupported type): {sample}{suffix}') def _sanitize_settings_payload(data: dict, emit_log: bool = False) -> dict: @@ -391,6 +408,11 @@ def _set_path(key: str, default: str = '') -> None: _set_int('max_bird_crops', default=10, min_value=1, max_value=20) _set_int('parallel_prefetch', default=3, min_value=1, max_value=5) _set_bool('exposure_corrected_thumbs', default=True) + # ONNX provider resilience (auto GPU↔CPU fallback). Advanced/triage knobs; + # not exposed in the UI. ``gpu_resilience_enabled=False`` reverts to the + # pre-resilience behavior (single GPU session, no recovery). + _set_bool('gpu_resilience_enabled', default=True) + _set_bool('gpu_aggressive_recreate', default=False) if 'wildlife_model_mode' in data: out['wildlife_model_mode'] = _coerce_enum( data.get('wildlife_model_mode'), @@ -399,7 +421,7 @@ def _set_path(key: str, default: str = '') -> None: ) if 'detector_name' in data: out['detector_name'] = _coerce_enum( - data.get('detector_name'), + _migrate_legacy_detector_name(data.get('detector_name')), _ALLOWED_QUEUE_DETECTOR_NAMES, default='mdv5a', ) @@ -529,7 +551,7 @@ def _load_settings_raw() -> tuple[dict | None, str]: with open(path, 'r', encoding='utf-8') as f: data = json.load(f) except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc: - log(f'[settings] WARN: could not parse {path}: {exc}') + warn(f'[settings] could not parse {path}: {exc}') return None, 'corrupt' if not isinstance(data, dict): return None, 'corrupt' @@ -546,7 +568,7 @@ def _load_backup_if_valid() -> dict | None: with open(bak, 'r', encoding='utf-8') as f: data = json.load(f) except (json.JSONDecodeError, OSError, UnicodeDecodeError) as exc: - log(f'[settings] WARN: .bak is also unreadable: {exc}') + warn(f'[settings] .bak is also unreadable: {exc}') return None return data if isinstance(data, dict) else None @@ -565,10 +587,10 @@ def _quarantine_corrupt_settings(path: str) -> str | None: attempt += 1 quarantine = f'{path}.corrupt-{ts}-{attempt}' shutil.copy2(path, quarantine) - log(f'[settings] Quarantined corrupt settings file to {quarantine}') + warn(f'[settings] Quarantined corrupt settings file to {quarantine}') return quarantine except OSError as exc: - log(f'[settings] Failed to quarantine corrupt settings: {exc}') + error(f'[settings] Failed to quarantine corrupt settings: {exc}') return None @@ -615,7 +637,7 @@ def load_persisted_settings() -> dict: if status == 'corrupt': bak_data = _load_backup_if_valid() if bak_data is not None: - log('[settings] Main settings file is corrupt; serving from .bak.') + warn('[settings] Main settings file is corrupt; serving from .bak.') return _sanitize_settings_payload(bak_data, emit_log=False) return {} @@ -670,7 +692,7 @@ def save_persisted_settings(data: dict) -> None: if status == 'corrupt': bak_data = _load_backup_if_valid() if bak_data is None: - log( + error( f'[settings] REFUSING to save over unreadable {path}; ' f'no valid .bak to recover from. ' f'Remove or repair the file manually, then retry.' @@ -698,9 +720,9 @@ def save_persisted_settings(data: dict) -> None: if data.get('analytics_opted_in', False) and _telemetry is not None: try: _telemetry.send_folder_analytics(**pending) - log('[analytics] Flushed pending detailed analytics after opt-in.') + info('[analytics] Flushed pending detailed analytics after opt-in.') except Exception as e: - log(f'[analytics] Failed to flush pending analytics: {e}') + warn(f'[analytics] Failed to flush pending analytics: {e}') # ------------------------------------------ os.makedirs(directory, exist_ok=True) @@ -714,7 +736,7 @@ def save_persisted_settings(data: dict) -> None: dir=directory, ) except OSError as exc: - log( + error( f'[settings] FAILED to create temp file for atomic save ({exc}); ' f'existing file left unchanged. Check disk space and permissions ' f'on {directory}.' @@ -744,14 +766,14 @@ def save_persisted_settings(data: dict) -> None: try: shutil.copy2(path, path + '.bak') except OSError as exc: - log(f'[settings] WARN: could not refresh .bak: {exc}') + warn(f'[settings] could not refresh .bak: {exc}') os.replace(tmp, path) except OSError as exc: # Do NOT fall back to a non-atomic direct write — that path is how # partial writes corrupt settings.json in the first place. Leave the # existing (possibly older) file in place and log loudly. - log( + error( f'[settings] FAILED to atomically save settings ({exc}); ' f'existing file left unchanged. Check disk space, permissions, ' f'and antivirus locks on {path}.' @@ -765,8 +787,58 @@ def save_persisted_settings(data: dict) -> None: _reap_orphan_tmp_files(directory) -def log(*args): - print('[serve]', *args, file=sys.stderr) +# --------------------------------------------------------------------------- +# Leveled logging +# --------------------------------------------------------------------------- +# All output goes to stderr (captured by ``_TeeStream`` in visualizer.py into +# ~/.kestrel/logs/kestrel_runtime_*.log). Threshold is read per-emit from the +# ``KESTREL_LOG_LEVEL`` env var; default INFO. Setting it to DEBUG re-enables +# the per-image / per-detection / per-batch traces that are normally silenced. +# +# Line format: ``[serve][LEVEL] ``. Callers preserve their existing +# ``[area]`` tag inside the message (e.g. ``[settings] ...``, ``[culling] ...``) +# for grep-filtering. + +_LEVELS = ('DEBUG', 'INFO', 'WARN', 'ERROR') +_LEVEL_VALUES = {name: idx for idx, name in enumerate(_LEVELS)} + + +def _current_log_threshold() -> str: + raw = os.environ.get('KESTREL_LOG_LEVEL', 'INFO').upper() + return raw if raw in _LEVEL_VALUES else 'INFO' + + +def _emit_log(level: str, args: tuple) -> None: + if _LEVEL_VALUES[level] < _LEVEL_VALUES[_current_log_threshold()]: + return + msg = ' '.join(str(a) for a in args) + print(f'[serve][{level}] {msg}', file=sys.stderr, flush=True) + + +# The level helpers accept and discard arbitrary kwargs so they're a +# drop-in replacement for ``print(..., flush=True)`` call sites without +# requiring every conversion to also strip the keyword arguments. + +def debug(*args, **_kwargs) -> None: + _emit_log('DEBUG', args) + + +def info(*args, **_kwargs) -> None: + _emit_log('INFO', args) + + +def warn(*args, **_kwargs) -> None: + _emit_log('WARN', args) + + +def error(*args, **_kwargs) -> None: + _emit_log('ERROR', args) + + +# Backwards-compat alias. Existing ``log(...)`` call sites stay valid and +# emit at INFO level; we retag the ones that should be WARN/ERROR/DEBUG +# individually. +log = info def _normalize(p: str) -> str: diff --git a/analyzer/shutdown_watch.py b/analyzer/shutdown_watch.py new file mode 100644 index 00000000..ad3273ec --- /dev/null +++ b/analyzer/shutdown_watch.py @@ -0,0 +1,246 @@ +"""OS-shutdown / logoff / reboot detection. + +Distinguishes "the OS told the process to exit" (reboot, logoff, power off) +from "the application crashed" so the next launch can suppress the false +crash dialog. See ``visualizer._mark_session_exit_reason`` for the consumer. + +Single public entry point: :func:`install`. Best-effort and failsafe — any +import or setup error is swallowed; shutdown reporting is a quality-of-life +feature, not a correctness requirement. + +Per-platform strategy: + +* **Linux**: ``SIGTERM`` and ``SIGHUP`` handlers. systemd / GNOME / most + desktop session managers send SIGTERM with a short grace period before + SIGKILL; SIGHUP covers TTY logout. The handler marks exit reason and + returns; the OS will follow up with SIGKILL. +* **macOS**: ``NSWorkspaceWillPowerOffNotification`` observer on the + shared workspace notification center. Skipped if PyObjC is unavailable. +* **Windows**: hidden ``ctypes`` window pumped on a daemon thread, listening + for ``WM_QUERYENDSESSION`` / ``WM_ENDSESSION``. ``SetConsoleCtrlHandler`` + is also registered as a belt-and-braces fallback (no-op for ``--windowed`` + PyInstaller builds where there's no console). +""" + +from __future__ import annotations + +import os +import sys +import threading +from typing import Callable, Optional + +_installed = False +_lock = threading.Lock() +# Module-level retain for macOS observer / Windows window class so they +# aren't garbage-collected. +_keepalive: list = [] + + +def install(callback: Callable[[], None]) -> bool: + """Register OS-shutdown listeners that invoke ``callback`` once. + + The callback receives no arguments and its return value is ignored. + It MUST be fast (single small file write) — on Linux it runs in a + signal handler context, and on Windows it runs in the message-pump + thread during the system's shutdown grace window. + + Returns True if at least one listener was installed. + """ + global _installed + with _lock: + if _installed: + return True + + wrapped = _wrap_once(callback) + installed_any = False + + try: + if sys.platform.startswith('win'): + installed_any |= _install_windows(wrapped) + elif sys.platform == 'darwin': + installed_any |= _install_macos(wrapped) + else: + installed_any |= _install_posix(wrapped) + except Exception: + pass + + if os.environ.get('KESTREL_FAKE_OS_SHUTDOWN') == '1': + try: + threading.Timer(0.5, wrapped).start() + installed_any = True + except Exception: + pass + + _installed = installed_any + return installed_any + + +def _wrap_once(callback: Callable[[], None]) -> Callable[[], None]: + """Return a wrapper that runs ``callback`` at most once, swallowing errors.""" + fired = threading.Event() + + def _once(*_a, **_kw): + if fired.is_set(): + return + fired.set() + try: + callback() + except Exception: + pass + + return _once + + +def _install_posix(callback: Callable[[], None]) -> bool: + import signal + + def _handler(signum, _frame): + callback() + # Don't raise — let pywebview's runloop continue. The OS will + # follow up with SIGKILL during the shutdown grace window. If we + # raised SystemExit here the finally-block would overwrite the + # exit_reason with 'clean', which is harmless but loses metadata. + + installed = False + for sig_name in ('SIGTERM', 'SIGHUP'): + sig = getattr(signal, sig_name, None) + if sig is None: + continue + try: + signal.signal(sig, _handler) + installed = True + except (ValueError, OSError): + # ValueError: not on main thread. OSError: signal not allowed. + pass + return installed + + +def _install_macos(callback: Callable[[], None]) -> bool: + try: + from AppKit import NSWorkspace # type: ignore[import-not-found] + from Foundation import NSObject # type: ignore[import-not-found] + import objc # type: ignore[import-not-found] # noqa: F401 + except Exception: + return False + + class _ShutdownObserver(NSObject): # type: ignore[misc] + def powerOff_(self, _notification): + callback() + + observer = _ShutdownObserver.alloc().init() + try: + center = NSWorkspace.sharedWorkspace().notificationCenter() + center.addObserver_selector_name_object_( + observer, + 'powerOff:', + 'NSWorkspaceWillPowerOffNotification', + None, + ) + except Exception: + return False + _keepalive.append(observer) + return True + + +def _install_windows(callback: Callable[[], None]) -> bool: + try: + import ctypes + from ctypes import wintypes + except Exception: + return False + + WM_QUERYENDSESSION = 0x0011 + WM_ENDSESSION = 0x0016 + WM_DESTROY = 0x0002 + + user32 = ctypes.WinDLL('user32', use_last_error=True) + kernel32 = ctypes.WinDLL('kernel32', use_last_error=True) + + WNDPROC = ctypes.WINFUNCTYPE( + ctypes.c_long, + wintypes.HWND, wintypes.UINT, wintypes.WPARAM, wintypes.LPARAM, + ) + + def _wnd_proc(hwnd, msg, wparam, lparam): + if msg in (WM_QUERYENDSESSION, WM_ENDSESSION): + callback() + if msg == WM_QUERYENDSESSION: + return 1 # TRUE — we accept shutdown + return 0 + if msg == WM_DESTROY: + user32.PostQuitMessage(0) + return 0 + return user32.DefWindowProcW(hwnd, msg, wparam, lparam) + + proc = WNDPROC(_wnd_proc) + _keepalive.append(proc) + + class WNDCLASS(ctypes.Structure): + _fields_ = [ + ('style', wintypes.UINT), + ('lpfnWndProc', WNDPROC), + ('cbClsExtra', ctypes.c_int), + ('cbWndExtra', ctypes.c_int), + ('hInstance', wintypes.HINSTANCE), + ('hIcon', wintypes.HICON), + ('hCursor', wintypes.HANDLE), + ('hbrBackground', wintypes.HBRUSH), + ('lpszMenuName', wintypes.LPCWSTR), + ('lpszClassName', wintypes.LPCWSTR), + ] + + h_instance = kernel32.GetModuleHandleW(None) + class_name = 'KestrelShutdownWatch' + + wc = WNDCLASS() + wc.lpfnWndProc = proc + wc.hInstance = h_instance + wc.lpszClassName = class_name + _keepalive.append(wc) + + user32.RegisterClassW.restype = wintypes.ATOM + atom = user32.RegisterClassW(ctypes.byref(wc)) + if not atom: + # Class name may already be registered if install() were retried; ignore. + pass + + user32.CreateWindowExW.restype = wintypes.HWND + hwnd = user32.CreateWindowExW( + 0, class_name, 'KestrelShutdownWatch', + 0, 0, 0, 0, 0, + None, None, h_instance, None, + ) + if not hwnd: + return False + + def _pump(): + msg = wintypes.MSG() + while user32.GetMessageW(ctypes.byref(msg), None, 0, 0) > 0: + user32.TranslateMessage(ctypes.byref(msg)) + user32.DispatchMessageW(ctypes.byref(msg)) + + t = threading.Thread(target=_pump, name='KestrelShutdownWatch', daemon=True) + t.start() + _keepalive.append(t) + + # Belt-and-braces: SetConsoleCtrlHandler. No-op in --windowed + # PyInstaller builds (no console), but free in console builds. + try: + HANDLER_ROUTINE = ctypes.WINFUNCTYPE(wintypes.BOOL, wintypes.DWORD) + CTRL_SHUTDOWN_EVENT = 6 + CTRL_LOGOFF_EVENT = 5 + CTRL_CLOSE_EVENT = 2 + + def _ctrl(event): + if event in (CTRL_SHUTDOWN_EVENT, CTRL_LOGOFF_EVENT, CTRL_CLOSE_EVENT): + callback() + return True + return False + + ctrl = HANDLER_ROUTINE(_ctrl) + _keepalive.append(ctrl) + kernel32.SetConsoleCtrlHandler(ctrl, True) + except Exception: + pass + + return True diff --git a/analyzer/tests/compat/__init__.py b/analyzer/tests/compat/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/compat/test_database_migration.py b/analyzer/tests/compat/test_database_migration.py new file mode 100644 index 00000000..287dc239 --- /dev/null +++ b/analyzer/tests/compat/test_database_migration.py @@ -0,0 +1,159 @@ +"""Backwards-compatibility tests for database migration. + +Loads legacy database CSV fixtures (generated by cloud agent — see +fixtures/legacy_databases/CLOUD_AGENT_INSTRUCTIONS.md) and verifies that +load_database() correctly migrates them to the current schema. + +Tests skip cleanly if no fixtures are present yet. +""" + +import pytest +import shutil +import pandas as pd +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.database import ( + load_database, + load_scenedata, + _needs_upgrade, + BASE_COLUMNS, + LEGACY_USER_COLUMNS, +) + + +pytestmark = pytest.mark.compat + + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "legacy_databases" + + +def _list_legacy_fixtures(): + """Return list of legacy database CSV fixture paths.""" + if not FIXTURE_DIR.exists(): + return [] + return sorted([f for f in FIXTURE_DIR.glob("v_*_kestrel_database.csv")]) + + +_LEGACY_FIXTURES = _list_legacy_fixtures() + +_skip_no_fixtures = pytest.mark.skipif( + not _LEGACY_FIXTURES, + reason="No legacy database fixtures present yet — pending cloud agent work." +) + + +@pytest.fixture +def legacy_csv_in_temp_dir(tmp_path, request): + """Copy a legacy CSV fixture into a temp .kestrel/ dir for migration testing.""" + fixture_path = request.param + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + target_csv = kestrel_dir / "kestrel_database.csv" + shutil.copy(fixture_path, target_csv) + return tmp_path, fixture_path + + +# Parametrize across every legacy fixture present +@_skip_no_fixtures +@pytest.mark.parametrize( + "legacy_csv_in_temp_dir", + _LEGACY_FIXTURES if _LEGACY_FIXTURES else [None], + indirect=True, + ids=[p.name for p in _LEGACY_FIXTURES] if _LEGACY_FIXTURES else ["no-fixtures"] +) +class TestLegacyDatabaseMigration: + """Tests that run against every legacy fixture.""" + + def test_legacy_db_needs_upgrade(self, legacy_csv_in_temp_dir): + """A legacy DB with old columns should report needs_upgrade=True.""" + tmp_path, fixture_path = legacy_csv_in_temp_dir + kestrel_dir = tmp_path / ".kestrel" + + # Load the fixture as a DataFrame + df = pd.read_csv(kestrel_dir / "kestrel_database.csv") + + # If this fixture has any legacy user columns AND no scenedata yet, it should need upgrade + has_legacy = any(col in df.columns for col in LEGACY_USER_COLUMNS) + if has_legacy: + assert _needs_upgrade(df, str(kestrel_dir)) == True + + def test_migration_completes_without_error(self, legacy_csv_in_temp_dir): + """load_database() must succeed on every legacy fixture.""" + tmp_path, fixture_path = legacy_csv_in_temp_dir + kestrel_dir = tmp_path / ".kestrel" + + db, db_path = load_database(str(kestrel_dir), "test_analyzer", None) + assert isinstance(db, pd.DataFrame) + assert len(db) > 0 # All fixtures have 4 rows + + def test_migration_no_filenames_lost(self, legacy_csv_in_temp_dir): + """No filenames are lost during migration.""" + tmp_path, fixture_path = legacy_csv_in_temp_dir + kestrel_dir = tmp_path / ".kestrel" + + original = pd.read_csv(kestrel_dir / "kestrel_database.csv") + original_filenames = set(original['filename'].dropna()) + + db, db_path = load_database(str(kestrel_dir), "test_analyzer", None) + migrated_filenames = set(db['filename'].dropna()) + + # All original filenames must be present + assert original_filenames == migrated_filenames + + def test_legacy_columns_removed_after_migration(self, legacy_csv_in_temp_dir): + """After migration, legacy user columns are not in the live DataFrame.""" + tmp_path, fixture_path = legacy_csv_in_temp_dir + kestrel_dir = tmp_path / ".kestrel" + + original = pd.read_csv(kestrel_dir / "kestrel_database.csv") + had_legacy = any(col in original.columns for col in LEGACY_USER_COLUMNS) + + if not had_legacy: + pytest.skip(f"{fixture_path.name} has no legacy columns to migrate") + + db, _ = load_database(str(kestrel_dir), "test_analyzer", None) + + # Legacy columns should not appear in the migrated DataFrame + for col in LEGACY_USER_COLUMNS: + assert col not in db.columns, ( + f"Migration failed to drop legacy column '{col}' from {fixture_path.name}" + ) + + def test_rating_migrated_to_scenedata(self, legacy_csv_in_temp_dir): + """If fixture had inline `rating` column, values appear in scenedata after migration.""" + tmp_path, fixture_path = legacy_csv_in_temp_dir + kestrel_dir = tmp_path / ".kestrel" + + original = pd.read_csv(kestrel_dir / "kestrel_database.csv") + if 'rating' not in original.columns: + pytest.skip(f"{fixture_path.name} has no rating column") + + # Run migration via load_database + load_database(str(kestrel_dir), "test_analyzer", None) + + # Check scenedata was created with rating info + scenedata = load_scenedata(str(kestrel_dir)) + # Scenedata should have image_ratings populated + image_ratings = scenedata.get('image_ratings', {}) + # At least some ratings should have been transferred + non_zero_ratings = {fn: r for fn, r in image_ratings.items() if r} + assert len(non_zero_ratings) > 0, ( + f"Expected rating values to transfer to scenedata from {fixture_path.name}" + ) + + +class TestCurrentSchemaNoUpgrade: + """Verify that the CURRENT schema is correctly identified as not needing migration.""" + + def test_fresh_current_db_no_upgrade(self, tmp_path): + """A freshly-created DB with current schema → needs_upgrade returns False.""" + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + + df = pd.DataFrame(columns=BASE_COLUMNS) + df.to_csv(kestrel_dir / "kestrel_database.csv", index=False) + + assert _needs_upgrade(df, str(kestrel_dir)) == False diff --git a/analyzer/tests/compat/test_settings_migration.py b/analyzer/tests/compat/test_settings_migration.py new file mode 100644 index 00000000..5089cf18 --- /dev/null +++ b/analyzer/tests/compat/test_settings_migration.py @@ -0,0 +1,182 @@ +"""Backwards-compatibility tests for settings.json forward/backward compat. + +Loads legacy settings.json fixtures (generated by cloud agent — see +fixtures/legacy_settings/CLOUD_AGENT_INSTRUCTIONS.md) and verifies that +load_persisted_settings() / _sanitize_settings_payload() correctly handle +every historical key-set. + +Tests skip cleanly if no fixtures are present yet. +""" + +import pytest +import json +from pathlib import Path +import sys +from unittest import mock + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from settings_utils import ( + _sanitize_settings_payload, + _apply_monotonic_guard, + load_persisted_settings, + save_persisted_settings, +) + + +FIXTURE_DIR = Path(__file__).parent.parent / "fixtures" / "legacy_settings" + + +def _list_legacy_settings_fixtures(): + """Return list of settings JSON fixture paths.""" + if not FIXTURE_DIR.exists(): + return [] + return sorted([f for f in FIXTURE_DIR.glob("settings_v_*.json")]) + + +_LEGACY_SETTINGS = _list_legacy_settings_fixtures() + +_skip_no_settings_fixtures = pytest.mark.skipif( + not _LEGACY_SETTINGS, + reason="No legacy settings fixtures present yet — pending cloud agent work." +) + +pytestmark = pytest.mark.compat + + +def _load_fixture_dict(path: Path) -> dict: + with open(path, encoding='utf-8') as f: + return json.load(f) + + +@_skip_no_settings_fixtures +@pytest.mark.parametrize( + "fixture_path", + _LEGACY_SETTINGS if _LEGACY_SETTINGS else [None], + ids=[p.name for p in _LEGACY_SETTINGS] if _LEGACY_SETTINGS else ["no-fixtures"] +) +class TestLegacySettingsLoad: + """Tests that run against every settings fixture.""" + + def test_sanitize_does_not_crash(self, fixture_path): + """_sanitize_settings_payload must succeed on every legacy fixture.""" + original = _load_fixture_dict(fixture_path) + result = _sanitize_settings_payload(original) + assert isinstance(result, dict) + + def test_no_known_keys_dropped(self, fixture_path): + """Every key in the fixture should still be present after sanitization.""" + original = _load_fixture_dict(fixture_path) + result = _sanitize_settings_payload(original) + + dropped = [k for k in original.keys() if k not in result] + # We allow dropping ONLY if the value type was clearly invalid; for these + # synthesized fixtures, values are always valid, so nothing should drop. + assert not dropped, f"{fixture_path.name}: dropped keys {dropped}" + + def test_monotonic_counters_preserved(self, fixture_path): + """Counters in the fixture are not zeroed or lowered after sanitization.""" + original = _load_fixture_dict(fixture_path) + result = _sanitize_settings_payload(original) + + for key in ('kestrel_impact_total_files', 'kestrel_impact_total_seconds'): + if key in original: + assert key in result, f"{fixture_path.name}: counter {key} disappeared" + # Values should be comparable (numeric) + assert result[key] >= original[key], ( + f"{fixture_path.name}: counter {key} regressed " + f"({original[key]} → {result[key]})" + ) + + +class TestForwardCompatibility: + """Tests that unknown 'future' keys pass through sanitization untouched.""" + + def test_with_future_keys_fixture(self): + """Fixtures named *_with_future_keys.json should keep unknown keys after sanitize.""" + future_fixtures = [f for f in _LEGACY_SETTINGS if "_with_future_keys" in f.name] + if not future_fixtures: + pytest.skip("No _with_future_keys fixtures present") + + for fixture_path in future_fixtures: + original = _load_fixture_dict(fixture_path) + result = _sanitize_settings_payload(original) + + # Find keys we expect to be "future"/unknown + # Heuristic: any key not in result is a problem + for key, val in original.items(): + if key not in result: + pytest.fail( + f"{fixture_path.name}: forward-compat key '{key}' was dropped" + ) + + +class TestDetectorNameMigration: + """The 'fast' detector was switched from mdv6-e to mdv1000-cedar at v2.0.2. + + Stored settings carrying the legacy name should be silently rewritten on + load — the user-facing 'Fast'/'Accurate' selector is unchanged. + """ + + def test_legacy_mdv6e_migrates_to_cedar(self): + result = _sanitize_settings_payload({'detector_name': 'mdv6-e'}) + assert result['detector_name'] == 'mdv1000-cedar' + + def test_legacy_mdv6e_case_insensitive(self): + for variant in ('MDv6-E', ' mdv6-e ', 'MDV6-E'): + result = _sanitize_settings_payload({'detector_name': variant}) + assert result['detector_name'] == 'mdv1000-cedar', f"failed for {variant!r}" + + def test_mdv5a_passes_through(self): + result = _sanitize_settings_payload({'detector_name': 'mdv5a'}) + assert result['detector_name'] == 'mdv5a' + + def test_cedar_passes_through(self): + result = _sanitize_settings_payload({'detector_name': 'mdv1000-cedar'}) + assert result['detector_name'] == 'mdv1000-cedar' + + def test_unknown_name_falls_back_to_default(self): + # _coerce_enum default when the value isn't allowlisted + result = _sanitize_settings_payload({'detector_name': 'made-up-name'}) + assert result['detector_name'] == 'mdv5a' + + def test_gambels_quail_fixture_migrates(self): + """The Gambels-Quail fixture carries detector_name='mdv6-e' on purpose + so this migration path is exercised whenever fixtures are regenerated.""" + fixture = FIXTURE_DIR / "settings_v_Gambels-Quail.json" + if not fixture.exists(): + pytest.skip("Gambels-Quail fixture not present") + original = _load_fixture_dict(fixture) + assert original.get('detector_name') == 'mdv6-e', ( + "Fixture should still carry the legacy name to exercise the migration" + ) + result = _sanitize_settings_payload(original) + assert result['detector_name'] == 'mdv1000-cedar' + + +class TestMonotonicGuardScenarios: + """Specific scenarios where the monotonic guard must hold across load/save.""" + + def test_save_with_lower_counter_preserves_higher(self): + """If saved settings have higher counter than incoming, higher wins.""" + existing = {'kestrel_impact_total_files': 1000} + incoming = {'kestrel_impact_total_files': 500} + + result = _apply_monotonic_guard(incoming, existing) + assert result['kestrel_impact_total_files'] == 1000 + + def test_omitted_counter_resurrected_from_existing(self): + """If incoming omits a counter, existing value is restored.""" + existing = {'kestrel_impact_total_files': 1000} + incoming = {'other_key': 'value'} + + result = _apply_monotonic_guard(incoming, existing) + assert result.get('kestrel_impact_total_files') == 1000 + + def test_higher_incoming_counter_used(self): + """Higher incoming counter is accepted.""" + existing = {'kestrel_impact_total_files': 1000} + incoming = {'kestrel_impact_total_files': 2000} + + result = _apply_monotonic_guard(incoming, existing) + assert result['kestrel_impact_total_files'] == 2000 diff --git a/analyzer/tests/conftest.py b/analyzer/tests/conftest.py new file mode 100644 index 00000000..beb91b8f --- /dev/null +++ b/analyzer/tests/conftest.py @@ -0,0 +1,109 @@ +"""Shared test fixtures for the Kestrel test suite.""" + +import json +import tempfile +from pathlib import Path +import pytest +import pandas as pd +import sys + +# Add analyzer to path so we can import kestrel_analyzer modules +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from kestrel_analyzer.database import BASE_COLUMNS, REQUIRED_COLUMNS + + +# ============================================================================ +# Fixtures for real image test sets +# ============================================================================ + +@pytest.fixture +def fixtures_dir(): + """Return path to the fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def set_a_path(fixtures_dir): + """Path to set_a_fresh/ (4 CR3 images, 2 scenes, no analysis).""" + return fixtures_dir / "test_sets" / "set_a_fresh" + + +@pytest.fixture +def set_b_paths(fixtures_dir): + """Dict of {ext: path} for diverse RAW formats in set_b_formats/.""" + test_sets_dir = fixtures_dir / "test_sets" / "set_b_formats" + result = {} + if test_sets_dir.exists(): + for ext in [".cr2", ".cr3", ".nef", ".arw", ".dng", ".orf", ".rw2", ".pef"]: + for file in test_sets_dir.glob(f"*{ext}"): + result[ext] = file + break + return result + + +@pytest.fixture +def set_c_path(fixtures_dir): + """Path to set_c_preanalyzed/ (4 CR3s with .kestrel/ dir).""" + return fixtures_dir / "test_sets" / "set_c_preanalyzed" + + +@pytest.fixture +def set_c_kestrel_dir(set_c_path): + """Path to set_c_preanalyzed/.kestrel/""" + return set_c_path / ".kestrel" + + +@pytest.fixture +def set_d_path(fixtures_dir): + """Path to set_d_jpeg_only/ (4 JPEG-only images).""" + return fixtures_dir / "test_sets" / "set_d_jpeg_only" + + +@pytest.fixture +def set_e_path(fixtures_dir): + """Path to set_e_raw_jpg_mix/ (RAW+JPG pairs).""" + return fixtures_dir / "test_sets" / "set_e_raw_jpg_mix" + + +# ============================================================================ +# Fixtures for synthetic test data +# ============================================================================ + +@pytest.fixture +def temp_kestrel_dir(tmp_path): + """Create a temporary .kestrel directory with minimal CSV and JSON.""" + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + + # Create empty CSV with just headers + csv_path = kestrel_dir / "kestrel_database.csv" + headers = ",".join(BASE_COLUMNS) + csv_path.write_text(headers + "\n") + + # Create empty scenedata JSON + scenedata_path = kestrel_dir / "kestrel_scenedata.json" + scenedata_path.write_text(json.dumps({}, indent=2)) + + # Create minimal metadata JSON + metadata_path = kestrel_dir / "kestrel_metadata.json" + metadata_path.write_text(json.dumps({ + "kestrel_version": "2.0.1", + "analyzer_name": "test_analyzer", + "analyzed_utc": "2026-01-01T00:00:00Z" + }, indent=2)) + + return kestrel_dir + + +@pytest.fixture +def sample_database(): + """Create a minimal Pandas DataFrame matching BASE_COLUMNS.""" + data = {col: [] for col in BASE_COLUMNS} + return pd.DataFrame(data) + + +@pytest.fixture +def temp_output_dir(tmp_path): + """Create a temporary directory for test output.""" + return tmp_path / "output" diff --git a/analyzer/tests/finalize/__init__.py b/analyzer/tests/finalize/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/fixtures/legacy_databases/CLOUD_AGENT_INSTRUCTIONS.md b/analyzer/tests/fixtures/legacy_databases/CLOUD_AGENT_INSTRUCTIONS.md new file mode 100644 index 00000000..eccc706b --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/CLOUD_AGENT_INSTRUCTIONS.md @@ -0,0 +1,284 @@ +# Legacy Database Fixtures — Cloud Agent Task Brief + +## Goal + +Generate **authentic** `kestrel_database.csv` files for each historical Kestrel release by **actually running the pipeline** at each git tag against a small test image set. These fixtures power `analyzer/tests/compat/test_database_migration.py`, which verifies the current `_perform_db_upgrade()` migration correctly handles every legacy schema we've ever shipped. + +**Approach:** Don't hand-synthesize. Checkout each tag → run `cli.py` on a small set of CR3s → capture the resulting `.kestrel/kestrel_database.csv` → label it with the tag name. This produces real, exact, faithful fixtures rather than guesses about what schema versions looked like. + +**Important:** The current (main branch) schema does NOT need a fixture — tests exercise it directly against live code. You're only generating fixtures for **past** schemas that differ from today. Additionally, cli.py may not have existed on earlier tags. If you checkout a tag which does not have cli.py entirely, just skip that release with a note. + +--- + +## Background + +The current schema (see `analyzer/kestrel_analyzer/database.py`): +- **`BASE_COLUMNS`** — Pipeline-written columns only. +- **`LEGACY_USER_COLUMNS`** = `['rating', 'normalized_rating', 'scene_name', 'rating_origin']` — these were inline CSV columns in older versions; now migrated to `kestrel_scenedata.json` on first load by `_perform_db_upgrade()`. + +Older tags had: +- Legacy user columns (`rating`, `scene_name`, etc.) inline in CSV. +- Different `BASE_COLUMNS` lists. +- Some had no scenedata system at all. + +--- + +## Environment Setup + +The cloud agent needs: + +1. **Python 3.11** matching what the project uses. +2. **Git LFS** enabled — models live in LFS: + ```bash + git lfs install + git -C /repo lfs pull + ``` +3. **A working virtualenv per tag** — older tags may have older `requirements*.txt`. Setting up a fresh venv per tag is safest: + ```bash + python -m venv .venv-fixturegen + .venv-fixturegen/bin/pip install -r requirements-linux.txt # or platform equiv + .venv-fixturegen/bin/pip install pyinstaller # if needed + ``` +4. **A small set of test CR3 images** — copy these from main's `analyzer/tests/fixtures/test_sets/set_a_fresh/` BEFORE checking out older tags: + ```bash + # On main: + cp -r analyzer/tests/fixtures/test_sets/set_a_fresh /tmp/kestrel_fixture_input + ``` + +--- + +## Step-by-Step Procedure + +### Step 1: List release tags + +```bash +git -C /repo for-each-ref --sort=creatordate \ + --format='%(creatordate:short) %(refname:short)' refs/tags +``` + +Expected output (18 tags as of this writing): + +``` +2026-02-04 alpha-2026.02.04 +2026-02-04 Public-Release-1 +2026-02-04 public-release-alpha-1-(R2024.02.04) +2026-02-07 Sparrow +2026-02-11 test +2026-02-12 sparrow_2026.02.12 +2026-02-20 Finch +2026-02-24 Junco +2026-03-01 Goldfinch +2026-03-04 Chickadee +2026-03-08 Tufted-Titmouse +2026-03-10 Swamp-Sparrow +2026-03-10 Yellow-Warbler +2026-03-15 Lincolns-Sparrow +2026-03-17 Willow-Ptarmigan +2026-03-19 Willow-Ptarmigan-F1 +2026-04-02 Kentucky-Warbler +2026-04-23 Gambels-Quail +``` + +### Step 2: Save the test image input outside the repo + +Before checking out old tags (which may not have set_a_fresh/), stage the input images outside the repo: + +```bash +mkdir -p /tmp/kestrel_fixture_input +cp analyzer/tests/fixtures/test_sets/set_a_fresh/*.CR3 /tmp/kestrel_fixture_input/ +ls /tmp/kestrel_fixture_input/ # should show 4 CR3 files +``` + +### Step 3: For each tag, run the pipeline and capture the database + +For each tag in chronological order, run this script: + +```bash +TAG="" +WORKDIR=$(mktemp -d) +cp /tmp/kestrel_fixture_input/*.CR3 $WORKDIR/ + +git -C /repo checkout $TAG +git -C /repo lfs pull # ensure models are present at this tag + +# Inspect cli.py to understand the invocation form for this tag +git show $TAG:analyzer/cli.py | head -50 + +# Run the pipeline. The flags may have changed across tags: +# - Current form: python analyzer/cli.py --no-gpu +# - Some older tags may need different flags +# - If --no-gpu doesn't exist, try without it (the agent's machine may not have a GPU anyway) +# - If --parallel-prefetch exists, set it to 1 for determinism + +cd /repo +.venv-fixturegen/bin/python analyzer/cli.py $WORKDIR --no-gpu --parallel-prefetch 1 2>&1 | tail -20 + +# Capture the resulting database (if pipeline succeeded) +if [ -f "$WORKDIR/.kestrel/kestrel_database.csv" ]; then + # Use a sanitized tag name (dots → underscores) + SAFE_TAG=$(echo "$TAG" | tr '.' '_' | tr '/' '_') + cp "$WORKDIR/.kestrel/kestrel_database.csv" \ + /repo/analyzer/tests/fixtures/legacy_databases/v_${SAFE_TAG}_kestrel_database.csv + + # Also capture scenedata and metadata if present (for richer testing) + if [ -f "$WORKDIR/.kestrel/kestrel_scenedata.json" ]; then + cp "$WORKDIR/.kestrel/kestrel_scenedata.json" \ + /repo/analyzer/tests/fixtures/legacy_databases/v_${SAFE_TAG}_kestrel_scenedata.json + fi + if [ -f "$WORKDIR/.kestrel/kestrel_metadata.json" ]; then + cp "$WORKDIR/.kestrel/kestrel_metadata.json" \ + /repo/analyzer/tests/fixtures/legacy_databases/v_${SAFE_TAG}_kestrel_metadata.json + fi + echo "✓ Captured fixture for $TAG" +else + echo "✗ Pipeline did not produce database for $TAG — check logs" +fi + +rm -rf $WORKDIR +``` + +**Per-tag time budget:** ~20-60 seconds (pipeline runs 4 images). Total time: ~20 minutes for 18 tags. + +### Step 4: Handle tags where the pipeline can't run + +Some tags may fail because: +- CLI flags changed (try without `--no-gpu` / `--parallel-prefetch`) +- Dependencies don't install on current Python +- Models are incompatible with that tag's expected paths + +For each failing tag, document the failure reason in `SCHEMA_NOTES.md` and move on. **Do NOT hand-synthesize fixtures for failed tags** — better to have an incomplete-but-accurate fixture set than a complete-but-fabricated one. + +### Step 5: Deduplicate fixtures by unique schema + +After running all tags, multiple tags may have produced identical CSV headers. **Keep only the OLDEST fixture per unique header set**, delete the rest: + +```bash +# Compute header hash for each CSV and group +cd /repo/analyzer/tests/fixtures/legacy_databases +for f in v_*_kestrel_database.csv; do + head -1 "$f" | sha256sum | cut -c1-8 + echo " $f" +done | paste - - | sort | uniq -c | sort -rn +``` + +Manually delete duplicate fixtures (keeping the oldest tag in each group), and note groupings in `SCHEMA_NOTES.md`. + +### Step 6: Create `SCHEMA_NOTES.md` + +Write a `SCHEMA_NOTES.md` in `fixtures/legacy_databases/` documenting findings: + +```markdown +# Database Schema Evolution Notes + +## Procedure + +Generated by running `analyzer/cli.py` at each release tag against 4 CR3 images +from `set_a_fresh/`. CSV fixtures are the literal output of those runs. + +## Tag → Schema Mapping + +| Tag | Date | Fixture File | Unique Schema? | Notes | +|-----|------|--------------|----------------|-------| +| alpha-2026.02.04 | 2026-02-04 | v_alpha-2026_02_04_kestrel_database.csv | yes | Earliest, pre-migration | +| Public-Release-1 | 2026-02-04 | (dedup → alpha-2026_02_04) | no | Identical schema | +| ... | ... | ... | ... | ... | + +## Schema Differences From Current + +For each unique schema kept, document what differs from main: + +### v_alpha-2026_02_04 +- Has columns: `rating`, `normalized_rating`, `scene_name`, `rating_origin` (legacy) +- Missing columns from main: `exposure_correction`, `exposure_pipeline`, ... +- Column order differences: ... + +## Failed Tags + +| Tag | Failure Reason | +|-----|----------------| +| test | Pipeline crashed on dependency mismatch | +| ... | ... | + +## How tests use these fixtures + +`analyzer/tests/compat/test_database_migration.py` parametrizes across every +fixture and verifies the current `load_database()` / `_perform_db_upgrade()` +correctly handles each historical schema. +``` + +--- + +## Output Directory Structure (Expected) + +``` +analyzer/tests/fixtures/legacy_databases/ +├── CLOUD_AGENT_INSTRUCTIONS.md # this file +├── SCHEMA_NOTES.md # NEW — generate this +├── v_alpha-2026_02_04_kestrel_database.csv +├── v_alpha-2026_02_04_kestrel_scenedata.json # if scenedata system existed +├── v_alpha-2026_02_04_kestrel_metadata.json +├── v_Sparrow_kestrel_database.csv +├── ... (one set per unique schema) +``` + +--- + +## Verification + +Before finalizing the PR, verify with this snippet from repo root: + +```bash +cd /repo +python < 0, f"{f} is empty" + assert "filename" in df.columns, f"{f} missing 'filename'" +EOF +``` + +Then run the compat test suite to confirm fixtures activate cleanly: + +```bash +cd analyzer +python -m pytest tests/compat/test_database_migration.py -v +``` + +(Should report many parametrized tests passing, with each fixture exercised.) + +--- + +## What NOT To Do + +- ❌ Don't modify any source code outside `fixtures/legacy_databases/`. +- ❌ Don't hand-craft CSVs — only commit literal outputs from `cli.py` runs. +- ❌ Don't keep all 18 fixtures if many are duplicates — dedupe to unique schemas only. +- ❌ Don't generate a fixture for the CURRENT (main) schema — tests exercise live code. +- ❌ Don't commit very large files (crops, exports) — only the CSV/JSON. +- ❌ Don't strip rows from the CSV — preserve whatever the pipeline produced. + +--- + +## Edge Cases + +1. **Tag fails to run** — document in SCHEMA_NOTES.md, move on. Better to skip than fabricate. +2. **Pipeline produces error logs but still writes CSV** — keep the CSV; that IS the schema at that tag. +3. **Multiple consecutive tags produce identical schema** — dedupe to oldest only. +4. **Tag predates the cli.py entry point** — try `python -m analyzer.cli` or similar; if no CLI exists at that tag, skip. + +--- + +## When Complete + +Open a PR with: +- Subject: `test fixtures: legacy database schemas captured from release tags` +- Description: link to this doc, list fixtures generated, note dedup groupings, mention any failed tags. +- All fixture CSVs/JSONs + `SCHEMA_NOTES.md`. +- No other code changes. diff --git a/analyzer/tests/fixtures/legacy_databases/SCHEMA_NOTES.md b/analyzer/tests/fixtures/legacy_databases/SCHEMA_NOTES.md new file mode 100644 index 00000000..7b1bec92 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/SCHEMA_NOTES.md @@ -0,0 +1,59 @@ +# Database Schema Evolution Notes + +## Procedure + +Fixtures were generated by checking out each release tag and running `analyzer/cli.py` +against the 4 CR3 files from `analyzer/tests/fixtures/test_sets/set_a_fresh/`. +Committed CSV/JSON files are literal outputs from each tag run (no hand-synthesis). + +## Tag → Schema Mapping + +| Tag | Date | Fixture File | Unique Schema? | Notes | +|-----|------|--------------|----------------|-------| +| alpha-2026.02.04 | 2026-02-04 | `v_alpha-2026_02_04_kestrel_database.csv` | yes | Earliest captured schema | +| Public-Release-1 | 2026-02-04 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Sparrow | 2026-02-07 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| test | 2026-02-11 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| sparrow_2026.02.12 | 2026-02-12 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Finch | 2026-02-20 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Junco | 2026-02-24 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Goldfinch | 2026-03-01 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Chickadee | 2026-03-04 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Tufted-Titmouse | 2026-03-08 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Swamp-Sparrow | 2026-03-10 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Yellow-Warbler | 2026-03-10 | dedup → `v_alpha-2026_02_04_kestrel_database.csv` | no | Header matched alpha | +| Lincolns-Sparrow | 2026-03-15 | `v_Lincolns-Sparrow_kestrel_database.csv` | yes | First schema with `exposure_correction`/`detection_scores`/`capture_time` | +| Willow-Ptarmigan | 2026-03-17 | `v_Willow-Ptarmigan_kestrel_database.csv` | yes | Added `orientation` | +| Willow-Ptarmigan-F1 | 2026-03-19 | dedup → `v_Willow-Ptarmigan_kestrel_database.csv` | no | Header matched Willow-Ptarmigan | +| Kentucky-Warbler | 2026-04-02 | `v_Kentucky-Warbler_kestrel_database.csv` | yes | Added crops/exposure pipeline columns | +| Gambels-Quail | 2026-04-23 | dedup → `v_Kentucky-Warbler_kestrel_database.csv` | no | Header matched Kentucky-Warbler | + +## Schema Differences From Current + +Current `BASE_COLUMNS` count: 26. + +### `v_alpha-2026_02_04_kestrel_database.csv` (19 columns) +- Includes legacy inline user column: `rating` +- Missing current columns: `capture_time`, `crops_json`, `detection_scores`, `exposure_correction`, `exposure_meter_scale`, `exposure_pipeline`, `exposure_subject_stops`, `primary_crop_index` + +### `v_Lincolns-Sparrow_kestrel_database.csv` (21 columns) +- No inline legacy user columns +- Missing current columns: `crops_json`, `exposure_meter_scale`, `exposure_pipeline`, `exposure_subject_stops`, `primary_crop_index` + +### `v_Willow-Ptarmigan_kestrel_database.csv` (22 columns) +- Adds `orientation` compared with Lincolns-Sparrow +- Still missing: `crops_json`, `exposure_meter_scale`, `exposure_pipeline`, `exposure_subject_stops`, `primary_crop_index` + +### `v_Kentucky-Warbler_kestrel_database.csv` (27 columns) +- Includes all current `BASE_COLUMNS` +- Extra historical column not in current base schema: `orientation` + +## Failed Tags + +All fetched tags produced a database fixture (no skipped/failed tags in final capture run). + +## How tests use these fixtures + +`analyzer/tests/compat/test_database_migration.py` parametrizes over +`v_*_kestrel_database.csv` fixtures and verifies current migration logic can load and +upgrade every retained historical schema. diff --git a/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_database.csv b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_database.csv new file mode 100644 index 00000000..566e2a49 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_database.csv @@ -0,0 +1,5 @@ +filename,species,species_confidence,family,family_confidence,quality,export_path,crop_path,crops_json,primary_crop_index,scene_count,feature_similarity,feature_confidence,color_similarity,color_confidence,similar,secondary_species_list,secondary_species_scores,secondary_family_list,secondary_family_scores,exposure_correction,exposure_pipeline,exposure_subject_stops,exposure_meter_scale,detection_scores,capture_time,orientation +IMG_2014.CR3,Chipping Sparrow,0.7392290830612183,Sparrow sp.,0.9638204574584961,0.5896466487178572,.kestrel/export/IMG_2014_export.jpg,.kestrel/crop/IMG_2014_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel/crop/IMG_2014_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9981546998023987, ""species"": ""Chipping Sparrow"", ""species_confidence"": 0.7392290830612183, ""family"": ""Sparrow sp."", ""family_confidence"": 0.9638204574584961, ""quality"": 0.5896466487178572, ""rating"": 3, ""exposure_correction"": 0.148, ""exposure_pipeline"": ""no_auto_bright_metered_v1"", ""exposure_subject_stops"": 0.0665, ""exposure_meter_scale"": 1.058169, ""bbox"": {""x_min"": 2982, ""x_max"": 4888, ""y_min"": 1591, ""y_max"": 3497, ""width"": 1906, ""height"": 1906, ""x_min_norm"": 0.4269759450171821, ""x_max_norm"": 0.699885452462772, ""y_min_norm"": 0.34141630901287556, ""y_max_norm"": 0.7504291845493563, ""x_center_norm"": 0.5634306987399771, ""y_center_norm"": 0.5459227467811159}}]",0,1,-1,-1,-1,-1,False,"[""Chipping Sparrow""]",[0.7392290830612183],"[""Sparrow sp.""]",[0.9638204574584961],0.148,no_auto_bright_metered_v1,0.0665,1.058169,[0.9981546998023987],2025-05-29T07:49:10,landscape +IMG_2015.CR3,Chipping Sparrow,0.5989425182342529,Sparrow sp.,0.9598052501678467,0.5076911632123902,.kestrel/export/IMG_2015_export.jpg,.kestrel/crop/IMG_2015_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel/crop/IMG_2015_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9982397556304932, ""species"": ""Chipping Sparrow"", ""species_confidence"": 0.5989425182342529, ""family"": ""Sparrow sp."", ""family_confidence"": 0.9598052501678467, ""quality"": 0.5076911632123902, ""rating"": 3, ""exposure_correction"": 0.3077, ""exposure_pipeline"": ""no_auto_bright_metered_v1"", ""exposure_subject_stops"": 0.2339, ""exposure_meter_scale"": 1.05252, ""bbox"": {""x_min"": 3453, ""x_max"": 5067, ""y_min"": 2600, ""y_max"": 4214, ""width"": 1614, ""height"": 1614, ""x_min_norm"": 0.4944158075601375, ""x_max_norm"": 0.7255154639175257, ""y_min_norm"": 0.5579399141630901, ""y_max_norm"": 0.9042918454935622, ""x_center_norm"": 0.6099656357388317, ""y_center_norm"": 0.7311158798283262}}]",0,1,-1.0,-1.0,-1.0,-1.0,True,"[""Chipping Sparrow""]",[0.5989425182342529],"[""Sparrow sp.""]",[0.9598052501678467],0.3077,no_auto_bright_metered_v1,0.2339,1.05252,[0.9982397556304932],2025-05-29T07:49:11,landscape +IMG_3067.CR3,Yellow Warbler,0.7222609519958496,Warbler sp.,0.9304320216178894,0.47625089804126197,.kestrel/export/IMG_3067_export.jpg,.kestrel/crop/IMG_3067_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel/crop/IMG_3067_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9974247217178345, ""species"": ""Yellow Warbler"", ""species_confidence"": 0.7222609519958496, ""family"": ""Warbler sp."", ""family_confidence"": 0.9304320216178894, ""quality"": 0.47625089804126197, ""rating"": 3, ""exposure_correction"": 1.0215, ""exposure_pipeline"": ""no_auto_bright_metered_v1"", ""exposure_subject_stops"": 1.0972, ""exposure_meter_scale"": 0.948901, ""bbox"": {""x_min"": 4394, ""x_max"": 5225, ""y_min"": 1370, ""y_max"": 2201, ""width"": 831, ""height"": 831, ""x_min_norm"": 0.6291523482245132, ""x_max_norm"": 0.7481386025200458, ""y_min_norm"": 0.2939914163090129, ""y_max_norm"": 0.4723175965665236, ""x_center_norm"": 0.6886454753722795, ""y_center_norm"": 0.3831545064377682}}]",0,2,0.0,1.0,0,0,False,"[""Yellow Warbler""]",[0.7222609519958496],"[""Warbler sp.""]",[0.9304320216178894],1.0215,no_auto_bright_metered_v1,1.0972,0.948901,[0.9974247217178345],2025-05-30T05:55:35,landscape +IMG_3068.CR3,Yellow Warbler,0.7970553040504456,Warbler sp.,0.9578067660331726,0.4794951847450285,.kestrel/export/IMG_3068_export.jpg,.kestrel/crop/IMG_3068_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel/crop/IMG_3068_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9961304664611816, ""species"": ""Yellow Warbler"", ""species_confidence"": 0.7970553040504456, ""family"": ""Warbler sp."", ""family_confidence"": 0.9578067660331726, ""quality"": 0.4794951847450285, ""rating"": 3, ""exposure_correction"": 1.0164, ""exposure_pipeline"": ""no_auto_bright_metered_v1"", ""exposure_subject_stops"": 1.0634, ""exposure_meter_scale"": 0.967932, ""bbox"": {""x_min"": 4424, ""x_max"": 5244, ""y_min"": 1361, ""y_max"": 2181, ""width"": 820, ""height"": 820, ""x_min_norm"": 0.6334478808705613, ""x_max_norm"": 0.7508591065292096, ""y_min_norm"": 0.29206008583690984, ""y_max_norm"": 0.46802575107296135, ""x_center_norm"": 0.6921534936998854, ""y_center_norm"": 0.3800429184549356}}]",0,2,-1.0,-1.0,-1.0,-1.0,True,"[""Yellow Warbler""]",[0.7970553040504456],"[""Warbler sp.""]",[0.9578067660331726],1.0164,no_auto_bright_metered_v1,1.0634,0.967932,[0.9961304664611816],2025-05-30T05:55:35,landscape diff --git a/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_metadata.json b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_metadata.json new file mode 100644 index 00000000..ddb41438 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_metadata.json @@ -0,0 +1,112 @@ +{ + "version": "1.7.0", + "analyzer": "cli", + "created_utc": "2026-05-11T05:22:46.337736Z", + "database_file": "kestrel_database.csv", + "quality_distribution": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 2, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "quality_distribution_stored": true, + "exposure_pipeline_version": 2, + "exposure_render_mode": "no_auto_bright_metered_v1", + "exposure_compensation_profile": "aggressive" +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_scenedata.json b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_scenedata.json new file mode 100644 index 00000000..e3e79c8f --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Kentucky-Warbler_kestrel_scenedata.json @@ -0,0 +1,34 @@ +{ + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": [ + "IMG_2014.CR3", + "IMG_2015.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "2": { + "scene_id": "2", + "image_filenames": [ + "IMG_3067.CR3", + "IMG_3068.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + } + } +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_database.csv b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_database.csv new file mode 100644 index 00000000..bc17ce6c --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_database.csv @@ -0,0 +1,5 @@ +filename,species,species_confidence,family,family_confidence,quality,export_path,crop_path,scene_count,feature_similarity,feature_confidence,color_similarity,color_confidence,similar,secondary_species_list,secondary_species_scores,secondary_family_list,secondary_family_scores,exposure_correction,detection_scores,capture_time +IMG_2014.CR3,Chipping Sparrow,0.6149231195449829,Sparrow sp.,0.9088711738586426,0.5598734640307674,.kestrel/export/IMG_2014_export.jpg,.kestrel/crop/IMG_2014_crop.jpg,1,-1,-1,-1,-1,False,"[""Chipping Sparrow""]",[0.6149231195449829],"[""Sparrow sp.""]",[0.9088711738586426],-0.0636,[0.9990973472595215],2025-05-29T07:49:10 +IMG_2015.CR3,Chipping Sparrow,0.8188713192939758,Sparrow sp.,0.9743603467941284,0.47497330419244244,.kestrel/export/IMG_2015_export.jpg,.kestrel/crop/IMG_2015_crop.jpg,1,-1.0,-1.0,-1.0,-1.0,True,"[""Chipping Sparrow""]",[0.8188713192939758],"[""Sparrow sp.""]",[0.9743603467941284],-0.0198,[0.9986795783042908],2025-05-29T07:49:11 +IMG_3067.CR3,Yellow Warbler,0.7814439535140991,Warbler sp.,0.9485013484954834,0.32646406600600897,.kestrel/export/IMG_3067_export.jpg,.kestrel/crop/IMG_3067_crop.jpg,2,0.0017499999999999998,1.0,0.17225457313007628,0.4232549921940425,False,"[""Yellow Warbler""]",[0.7814439535140991],"[""Warbler sp.""]",[0.9485013484954834],-0.3686,[0.9978452920913696],2025-05-30T05:55:35 +IMG_3068.CR3,Yellow Warbler,0.8142870664596558,Warbler sp.,0.9619948267936707,0.3391649126794472,.kestrel/export/IMG_3068_export.jpg,.kestrel/crop/IMG_3068_crop.jpg,2,-1.0,-1.0,-1.0,-1.0,True,"[""Yellow Warbler""]",[0.8142870664596558],"[""Warbler sp.""]",[0.9619948267936707],-0.3521,[0.997767448425293],2025-05-30T05:55:35 diff --git a/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_metadata.json b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_metadata.json new file mode 100644 index 00000000..e1de97c8 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_metadata.json @@ -0,0 +1,109 @@ +{ + "version": "1.6.0", + "analyzer": "cli", + "created_utc": "2026-05-11T05:20:21.881665Z", + "database_file": "kestrel_database.csv", + "quality_distribution": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "quality_distribution_stored": true +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_scenedata.json b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_scenedata.json new file mode 100644 index 00000000..e3e79c8f --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Lincolns-Sparrow_kestrel_scenedata.json @@ -0,0 +1,34 @@ +{ + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": [ + "IMG_2014.CR3", + "IMG_2015.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "2": { + "scene_id": "2", + "image_filenames": [ + "IMG_3067.CR3", + "IMG_3068.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + } + } +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_database.csv b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_database.csv new file mode 100644 index 00000000..aa137be9 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_database.csv @@ -0,0 +1,5 @@ +filename,species,species_confidence,family,family_confidence,quality,export_path,crop_path,scene_count,feature_similarity,feature_confidence,color_similarity,color_confidence,similar,secondary_species_list,secondary_species_scores,secondary_family_list,secondary_family_scores,exposure_correction,detection_scores,capture_time,orientation +IMG_2014.CR3,Chipping Sparrow,0.6149231195449829,Sparrow sp.,0.9088711738586426,0.5598734640307674,.kestrel/export/IMG_2014_export.jpg,.kestrel/crop/IMG_2014_crop.jpg,1,-1,-1,-1,-1,False,"[""Chipping Sparrow""]",[0.6149231195449829],"[""Sparrow sp.""]",[0.9088711738586426],-0.0636,[0.9990973472595215],2025-05-29T07:49:10,landscape +IMG_2015.CR3,Chipping Sparrow,0.8096027970314026,Sparrow sp.,0.9760609269142151,0.47594452066502796,.kestrel/export/IMG_2015_export.jpg,.kestrel/crop/IMG_2015_crop.jpg,1,-1.0,-1.0,-1.0,-1.0,True,"[""Chipping Sparrow""]",[0.8096027970314026],"[""Sparrow sp.""]",[0.9760609269142151],-0.005,[0.9986795783042908],2025-05-29T07:49:11,landscape +IMG_3067.CR3,Yellow Warbler,0.7846366763114929,Warbler sp.,0.9468539357185364,0.3417120242060368,.kestrel/export/IMG_3067_export.jpg,.kestrel/crop/IMG_3067_crop.jpg,2,0.0033333333333333335,1.0,0,0,False,"[""Yellow Warbler""]",[0.7846366763114929],"[""Warbler sp.""]",[0.9468539357185364],-0.176,[0.9978452920913696],2025-05-30T05:55:35,landscape +IMG_3068.CR3,Yellow Warbler,0.8135492205619812,Warbler sp.,0.9614872336387634,0.3586320337421427,.kestrel/export/IMG_3068_export.jpg,.kestrel/crop/IMG_3068_crop.jpg,2,-1.0,-1.0,-1.0,-1.0,True,"[""Yellow Warbler""]",[0.8135492205619812],"[""Warbler sp.""]",[0.9614872336387634],-0.1594,[0.997767448425293],2025-05-30T05:55:35,landscape diff --git a/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_metadata.json b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_metadata.json new file mode 100644 index 00000000..17b4796b --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_metadata.json @@ -0,0 +1,109 @@ +{ + "version": "1.6.2", + "analyzer": "cli", + "created_utc": "2026-05-11T05:21:11.493395Z", + "database_file": "kestrel_database.csv", + "quality_distribution": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "quality_distribution_stored": true +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_scenedata.json b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_scenedata.json new file mode 100644 index 00000000..e3e79c8f --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_Willow-Ptarmigan_kestrel_scenedata.json @@ -0,0 +1,34 @@ +{ + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": [ + "IMG_2014.CR3", + "IMG_2015.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "2": { + "scene_id": "2", + "image_filenames": [ + "IMG_3067.CR3", + "IMG_3068.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + } + } +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_database.csv b/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_database.csv new file mode 100644 index 00000000..5c25c0a9 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_database.csv @@ -0,0 +1,5 @@ +filename,species,species_confidence,family,family_confidence,quality,export_path,crop_path,rating,scene_count,feature_similarity,feature_confidence,color_similarity,color_confidence,similar,secondary_species_list,secondary_species_scores,secondary_family_list,secondary_family_scores +IMG_2014.CR3,Chipping Sparrow,0.5215683579444885,Sparrow sp.,0.6720190644264221,0.9590288996696472,/tmp/tmp.65IOojUKl2/.kestrel/export/IMG_2014_export.jpg,/tmp/tmp.65IOojUKl2/.kestrel/crop/IMG_2014_crop.jpg,5,1,-1,-1,-1,-1,False,['Chipping Sparrow'],[0.52156836],['Sparrow sp.'],[0.67201906] +IMG_2015.CR3,Chipping Sparrow,0.936413049697876,Sparrow sp.,0.9856338500976562,0.8178989291191101,/tmp/tmp.65IOojUKl2/.kestrel/export/IMG_2015_export.jpg,/tmp/tmp.65IOojUKl2/.kestrel/crop/IMG_2015_crop.jpg,4,1,0.58,1.0,0,0,True,['Chipping Sparrow'],[0.93641305],['Sparrow sp.'],[0.98563385] +IMG_3067.CR3,Yellow Warbler,0.7810675501823425,Warbler sp.,0.9365131258964539,0.2677851915359497,/tmp/tmp.65IOojUKl2/.kestrel/export/IMG_3067_export.jpg,/tmp/tmp.65IOojUKl2/.kestrel/crop/IMG_3067_crop.jpg,2,2,0.0033333333333333335,1.0,0,0,False,"['Yellow Warbler' 'Yellow Rail' ""Vaux's Swift"" 'Common Nighthawk']",[0.78106755 0.21770558 0.20346816 0.16641276],['Warbler sp.' 'Sandpiper sp.' 'Swift sp.' 'Swift sp.'],[0.93651313 0.22481364 0.29409584 0.29247066] +IMG_3068.CR3,Yellow Warbler,0.8070620894432068,Warbler sp.,0.9612240791320801,0.296207994222641,/tmp/tmp.65IOojUKl2/.kestrel/export/IMG_3068_export.jpg,/tmp/tmp.65IOojUKl2/.kestrel/crop/IMG_3068_crop.jpg,2,2,0.24,1.0,0,0,True,['Yellow Warbler'],[0.80706209],['Warbler sp.'],[0.96122408] diff --git a/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_metadata.json b/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_metadata.json new file mode 100644 index 00000000..9f0b7780 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_databases/v_alpha-2026_02_04_kestrel_metadata.json @@ -0,0 +1,6 @@ +{ + "version": "1.2.0", + "analyzer": "cli", + "created_utc": "2026-05-11T05:10:59.714327Z", + "database_file": "kestrel_database.csv" +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_settings/CLOUD_AGENT_INSTRUCTIONS.md b/analyzer/tests/fixtures/legacy_settings/CLOUD_AGENT_INSTRUCTIONS.md new file mode 100644 index 00000000..bca9950f --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/CLOUD_AGENT_INSTRUCTIONS.md @@ -0,0 +1,337 @@ +# Legacy Settings Fixtures — Cloud Agent Task Brief + +## Goal + +Generate **authentic** `settings.json` files from each historical Kestrel release by **actually executing the settings code** at each git tag. These fixtures power `analyzer/tests/compat/test_settings_migration.py`, which verifies the current `load_persisted_settings()` / `_sanitize_settings_payload()` correctly handle every historical key-set. + +**Approach:** Don't hand-write JSON. Checkout each tag → import that tag's `settings_utils` module → call `save_persisted_settings()` with seeded values → capture the file from disk → label with the tag name. The settings sanitizer at each tag will write out exactly the keys it knew about at that version, with its own defaults applied. + +--- + +## Background + +Settings live at: +- **Windows:** `%LOCALAPPDATA%\ProjectKestrel\settings.json` +- **macOS:** `~/Library/Application Support/ProjectKestrel/settings.json` +- **Linux:** `~/.local/share/project-kestrel/settings.json` + +Each tag's `analyzer/settings_utils.py` defines: +- Which keys are valid at that version +- Default values +- Sanitization (coercion, clamping, enum allowlists) +- Monotonic counter guard (`kestrel_impact_total_files`, `kestrel_impact_total_seconds`) +- Forward-compat passthrough (in newer versions) + +We want the literal output of `save_persisted_settings()` at each tag. + +--- + +## Environment Setup + +The cloud agent needs: + +1. **Python 3.11**. +2. **A clean filesystem location for the per-tag settings file** — to avoid collisions, override the settings directory per-tag via env var or symlink. See "Tip: redirect settings path" below. +3. **No models or heavy deps needed** — settings code is pure Python; you don't need to install `requirements*.txt` for this task. + +### Tip: Redirect settings path to a temp dir + +The settings module reads `_get_user_data_dir()` from environment-derived paths. To avoid polluting your real `%LOCALAPPDATA%` or `~/Library`, you can: + +- **Option A (Linux/macOS):** Use `HOME=$tempdir python ...` or `XDG_DATA_HOME=$tempdir/.local/share python ...` +- **Option B (cross-platform):** Move/rename the resulting file after each capture. +- **Option C (cleanest):** Monkey-patch `_get_user_data_dir` in the capture script (see Step 3). + +--- + +## Step-by-Step Procedure + +### Step 1: List release tags + +```bash +git -C /repo for-each-ref --sort=creatordate \ + --format='%(creatordate:short) %(refname:short)' refs/tags +``` + +(Same 18 tags as for legacy databases — see that doc.) + +### Step 2: Write a per-tag capture script + +Save this as `/tmp/capture_settings.py`. It loads the tag's `settings_utils`, calls `load_persisted_settings()` to get a clean defaults dict, then seeds it with deliberate values and calls `save_persisted_settings()`, then reads the file off disk: + +```python +"""Capture script — run inside each tag's checkout. + +Outputs: prints the contents of the settings.json that the tag's own code wrote. +""" +import sys +import os +import json +import tempfile + +# Force settings into a temp dir so we don't touch the real user profile +TEMP_HOME = tempfile.mkdtemp(prefix='kestrel_fixture_') + +# Override platform user-data locations +os.environ['HOME'] = TEMP_HOME +os.environ['XDG_DATA_HOME'] = os.path.join(TEMP_HOME, '.local', 'share') +os.environ['LOCALAPPDATA'] = os.path.join(TEMP_HOME, 'AppData', 'Local') + +# Make analyzer importable +sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'analyzer')) + +import settings_utils + +# Seed values — these are deliberately maxed out / non-default so we can prove +# the sanitizer preserves them rather than silently resetting to defaults. +SEED = { + "editor": "/Applications/Adobe Lightroom Classic.app", + "rating_profile": "balanced", + "detection_threshold": 0.27, + "scene_time_threshold": 1500, + "mask_threshold": 0.55, + "max_bird_crops": 5, + "wildlife_model_mode": "balanced", + "detector_name": "mdv5a", + "exposure_quality": "balanced", + "thumbnail_max_width": 1200, + "thumbnail_jpeg_quality": 75, + "raw_preview_cache_enabled": True, + # Monotonic counters — deliberately high so the guard test can verify + # they're preserved across loads: + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, + # Some plausible future keys — sanitizers in tags WITH passthrough should + # preserve these; sanitizers WITHOUT passthrough should drop them silently. + # The test suite uses this to verify forward-compat behavior per tag. + "future_feature_xyz": True, + "future_unknown_key": "hello", +} + +# Save (calls the tag's own _sanitize_settings_payload first) +settings_utils.save_persisted_settings(SEED) + +# Read the file back from disk and emit to stdout +path = settings_utils._get_settings_path() +with open(path, 'r', encoding='utf-8') as f: + print(f.read()) +``` + +### Step 3: For each tag, run the capture + +```bash +TAG="" +SAFE_TAG=$(echo "$TAG" | tr '.' '_' | tr '/' '_') +OUT_FILE="/repo/analyzer/tests/fixtures/legacy_settings/settings_v_${SAFE_TAG}.json" + +git -C /repo checkout $TAG + +# Verify settings_utils exists at this tag +if [ ! -f /repo/analyzer/settings_utils.py ]; then + echo "✗ $TAG has no settings_utils.py — skipping" + continue +fi + +# Run the capture, redirecting stdout to the fixture file +python /tmp/capture_settings.py > $OUT_FILE 2>/tmp/capture_err_${SAFE_TAG}.log + +if [ -s "$OUT_FILE" ]; then + # Validate it parses as JSON + if python -c "import json; json.load(open('$OUT_FILE'))" 2>/dev/null; then + echo "✓ Captured $TAG → settings_v_${SAFE_TAG}.json" + else + echo "✗ $TAG produced invalid JSON — see capture_err_${SAFE_TAG}.log" + rm $OUT_FILE + fi +else + echo "✗ $TAG produced empty output — see capture_err_${SAFE_TAG}.log" + rm $OUT_FILE +fi +``` + +**Time budget:** ~1-2 seconds per tag. Total: <1 minute for 18 tags. + +### Step 4: Generate edge-case variants for the most recent legacy tag + +Pick the most recent legacy tag (not main — e.g., `Gambels-Quail` if it's still on the previous schema). Generate two extra fixtures: + +```python +# --- minimal variant --- +# Use only required keys + monotonic counters +SEED_MINIMAL = { + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, +} +# Save and capture as settings_v__minimal.json + +# --- maximal variant (with future keys) --- +# Already covered by main capture, but force a separate file +# Save and capture as settings_v__with_future_keys.json +# (Same seed as main script — this just labels the file with a hint) +``` + +### Step 5: Deduplicate fixtures by unique key-set + +Multiple tags may produce JSON with identical key sets. Keep the OLDEST tag per unique key-set: + +```bash +cd /repo/analyzer/tests/fixtures/legacy_settings +for f in settings_v_*.json; do + # Skip variants + case "$f" in *_minimal.json|*_with_future_keys.json) continue;; esac + + # Hash the sorted key list + python -c " +import json, sys, hashlib +d = json.load(open('$f')) +key_sig = ','.join(sorted(d.keys())) +print(hashlib.sha256(key_sig.encode()).hexdigest()[:8]) +" + echo " $f" +done | paste - - | sort | uniq -c | sort -rn +``` + +Manually delete duplicates, keeping only the oldest tag in each group. Document groupings in `KEY_EVOLUTION.md`. + +### Step 6: Create `KEY_EVOLUTION.md` + +```markdown +# Settings Key Evolution Notes + +## Procedure + +Generated by running each tag's own `settings_utils.save_persisted_settings()` +with a seeded payload, then capturing the resulting `settings.json` from disk. +This produces *literal* sanitizer output for each version. + +## Tag → Key-Set Mapping + +| Tag | Date | Fixture File | Unique Set? | Has Future-Key Passthrough? | Notes | +|-----|------|--------------|-------------|------------------------------|-------| +| alpha-2026.02.04 | 2026-02-04 | settings_v_alpha-2026_02_04.json | yes | no | Earliest | +| Sparrow | 2026-02-07 | (dedup → alpha-2026_02_04) | no | no | Identical | +| ... | ... | ... | ... | ... | ... | + +## Key Lifecycle + +For each key, note which tag first added it (the earliest tag where it appears +in the fixture output): + +| Key | First Seen Tag | Notes | +|-----|----------------|-------| +| `editor` | alpha-2026.02.04 | External editor path | +| `detection_threshold` | alpha-2026.02.04 | | +| `rating_profile` | Junco | Added when 5 profiles introduced | +| `wildlife_model_mode` | Tufted-Titmouse | | +| ... | ... | ... | + +## Monotonic Counters + +| Counter | First Seen | Type | Guard Tested? | +|---------|------------|------|---------------| +| `kestrel_impact_total_files` | | int | yes | +| `kestrel_impact_total_seconds` | | float | yes | + +## Forward-Compat Passthrough + +| Tag | Future keys preserved? | +|-----|------------------------| +| alpha-2026.02.04 | no — passthrough not yet introduced | +| ... | ... | +| Gambels-Quail | yes | + +## Failed Tags + +| Tag | Failure Reason | +|-----|----------------| +| test | settings_utils.py crash on import | +| ... | ... | +``` + +--- + +## Output Directory Structure (Expected) + +``` +analyzer/tests/fixtures/legacy_settings/ +├── CLOUD_AGENT_INSTRUCTIONS.md # this file +├── KEY_EVOLUTION.md # NEW — generate this +├── settings_v_alpha-2026_02_04.json +├── settings_v_Junco.json +├── settings_v_.json +├── settings_v_Gambels-Quail.json +├── settings_v_Gambels-Quail_minimal.json +├── settings_v_Gambels-Quail_with_future_keys.json +└── ... (one JSON per unique key-set + edge variants for newest tag) +``` + +--- + +## Verification + +Before finalizing, run: + +```bash +cd /repo +python < 0 +EOF +``` + +Then run the compat test suite — fixtures should activate the previously-skipped parametrized tests: + +```bash +cd analyzer +python -m pytest tests/compat/test_settings_migration.py -v +``` + +Also run the existing settings unit tests to confirm no regression: + +```bash +python -m pytest tests/unit/test_settings.py -v +``` + +(Should still report all 44 tests passing.) + +--- + +## What NOT To Do + +- ❌ Don't modify any source code outside `fixtures/legacy_settings/`. +- ❌ Don't hand-write JSON — only commit output from `save_persisted_settings()` runs. +- ❌ Don't pollute your real settings.json — always redirect via env vars or monkeypatch (see Step 2). +- ❌ Don't commit fixtures with real user data, credentials, or paths from your filesystem. +- ❌ Don't generate a fixture for main — tests exercise live code. +- ❌ Don't combine multiple unique key-sets into one fixture. + +--- + +## Edge Cases + +1. **Tag has no `settings_utils.py`** — skip, document in `KEY_EVOLUTION.md`. +2. **Tag's settings_utils crashes on import** — try installing minimal deps (`pip install` whatever it requires), or document failure and skip. +3. **`_get_settings_path` differs at that tag** — read the file from wherever that tag writes to. +4. **Tag has a different sanitizer behavior** (e.g., a key has different type / default than today) — the captured fixture should reflect that; the test will exercise our current loader's handling. +5. **Tag rejects unknown keys (no passthrough)** — the captured fixture will not contain `future_feature_xyz` / `future_unknown_key`. Note in `KEY_EVOLUTION.md`. Generate the `_with_future_keys.json` variant ONLY for tags that have passthrough. + +--- + +## When Complete + +Open a PR with: +- Subject: `test fixtures: legacy settings.json captured from release tags` +- Description: link to this doc, list fixtures generated, note dedup groupings, mention any failed tags. +- All fixture JSONs + `KEY_EVOLUTION.md`. +- No other code changes. diff --git a/analyzer/tests/fixtures/legacy_settings/KEY_EVOLUTION.md b/analyzer/tests/fixtures/legacy_settings/KEY_EVOLUTION.md new file mode 100644 index 00000000..c937cbd4 --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/KEY_EVOLUTION.md @@ -0,0 +1,145 @@ +# Settings Key Evolution Notes + +## Procedure + +Generated by running each tag's own `settings_utils.save_persisted_settings()` +with a seeded payload, then capturing the resulting `settings.json` from disk. +This produces *literal* sanitizer output for each version. + +For each tag, the tag's `analyzer/settings_utils.py` (and `analyzer/kestrel_telemetry.py` +when present, to satisfy the failsafe telemetry import) was extracted via +`git show :...` into a per-tag module dir. The capture script (see +`/tmp/capture_settings.py` in the working environment) then forced `HOME` / +`XDG_DATA_HOME` / `LOCALAPPDATA` to a temp dir, imported the extracted +`settings_utils`, called `save_persisted_settings(SEED)`, and read the +resulting JSON back from disk. + +The same seed payload was used across every tag so any differences in the +resulting fixture reflect that tag's sanitizer behaviour (key allowlist, +enum coercion, value clamping, forward-compat passthrough). + +## Tag → Key-Set Mapping + +Of the 17 release tags inspected, only 5 contain `analyzer/settings_utils.py`: +the persisted-settings system was introduced at `Lincolns-Sparrow`. The +remaining 12 earlier tags have no settings file system at all and are +documented under "Tags Without `settings_utils.py`" below. + +| Tag | Date | Fixture File | Unique Set? | Future-Key Passthrough? | Notes | +|----------------------|------------|-------------------------------------------------------|-------------|--------------------------|-------| +| Lincolns-Sparrow | 2026-03-15 | `settings_v_Lincolns-Sparrow.json` | yes | yes (by absence of sanitizer) | First tag with `settings_utils.py`. No sanitizer — `save_persisted_settings` writes the dict verbatim. | +| Willow-Ptarmigan | 2026-03-17 | (dedup → Lincolns-Sparrow) | no | yes (by absence of sanitizer) | Byte-identical to Lincolns-Sparrow fixture. | +| Willow-Ptarmigan-F1 | 2026-03-19 | (dedup → Lincolns-Sparrow) | no | yes (by absence of sanitizer) | Byte-identical to Lincolns-Sparrow fixture. | +| Kentucky-Warbler | 2026-04-02 | `settings_v_Kentucky-Warbler.json` | yes | no | First tag with `_sanitize_settings_payload`. Strict allowlist — unknown keys (including `future_*`, `detector_name`, `exposure_quality`, `thumbnail_*`, `wildlife_model_mode`) are dropped silently. | +| Gambels-Quail | 2026-04-23 | `settings_v_Gambels-Quail.json` | (same key-set as Lincolns-Sparrow, kept for value-level coverage) | yes (explicit `_PASSTHROUGH_*` rules) | Sanitizer with forward-compat passthrough. Many values are coerced (see "Value-level coercion" below). | +| Gambels-Quail | 2026-04-23 | `settings_v_Gambels-Quail_minimal.json` | (variant) | n/a | Edge variant: only the two monotonic counters seeded. | +| Gambels-Quail | 2026-04-23 | `settings_v_Gambels-Quail_with_future_keys.json` | (variant) | yes | Edge variant: identical to the main Gambels-Quail capture, separate file so `test_with_future_keys_fixture` can identify it by name. | + +### Note on the Gambels-Quail main capture + +The strict key-set dedup rule from the brief would dedup `settings_v_Gambels-Quail.json` +to Lincolns-Sparrow (both have the same 16 keys). It is kept anyway because the +**values** differ in ways that exercise the current loader's coercion path: + +| Key | Lincolns-Sparrow (raw seed) | Gambels-Quail (after its sanitizer) | +|-------------------------|------------------------------|--------------------------------------| +| `editor` | `"/Applications/Adobe Lightroom Classic.app"` | `"darktable"` (coerced — full path not in allowlist) | +| `detector_name` | `"mdv5a"` | `"mdv6-e"` (coerced — `mdv5a` removed from allowlist at this tag; at v2.0.2 this fixture value migrates silently to `"mdv1000-cedar"`) | +| `scene_time_threshold` | `1500` | `60.0` (clamped to max) | +| `wildlife_model_mode` | `"balanced"` | `"accurate"` (coerced — `balanced` not in allowlist) | + +These coerced values are what older Gambels-Quail installs may have on disk, +so it is useful for the current loader to be tested against them. + +### Tags Without `settings_utils.py` + +| Tag | Date | Reason for skip | +|-----------------------|------------|-----------------| +| alpha-2026.02.04 | 2026-02-04 | No persisted-settings system at this tag. | +| Public-Release-1 | 2026-02-04 | No persisted-settings system at this tag. | +| Sparrow | 2026-02-07 | No persisted-settings system at this tag. | +| test | 2026-02-11 | No persisted-settings system at this tag. | +| sparrow_2026.02.12 | 2026-02-12 | No persisted-settings system at this tag. | +| Finch | 2026-02-20 | No persisted-settings system at this tag. | +| Junco | 2026-02-24 | No persisted-settings system at this tag. | +| Goldfinch | 2026-03-01 | No persisted-settings system at this tag. | +| Chickadee | 2026-03-04 | No persisted-settings system at this tag. | +| Tufted-Titmouse | 2026-03-08 | No persisted-settings system at this tag. | +| Swamp-Sparrow | 2026-03-10 | No persisted-settings system at this tag. | +| Yellow-Warbler | 2026-03-10 | No persisted-settings system at this tag. | + +`git ls-tree -r --name-only ` reports no `settings_utils.py` (or any +other settings file) for any of these tags. The settings system first appears +at `Lincolns-Sparrow` (2026-03-15). + +The tag list `public-release-alpha-1-(R2024.02.04)` mentioned in +`CLOUD_AGENT_INSTRUCTIONS.md` is not present in this repository (only 17 of +the 18 tags from the brief exist). Pre-Lincolns-Sparrow tags would be skipped +regardless. + +## Key Lifecycle + +Earliest tag where each key appears in the saved-fixture output: + +| Key | First Seen Tag | Notes | +|-----------------------------------|---------------------|-------| +| `editor` | Lincolns-Sparrow | Free-form path until Kentucky-Warbler; enum allowlist from Kentucky-Warbler onward. | +| `rating_profile` | Lincolns-Sparrow | | +| `detection_threshold` | Lincolns-Sparrow | | +| `scene_time_threshold` | Lincolns-Sparrow | Coerced to float and clamped to max at Gambels-Quail. | +| `mask_threshold` | Lincolns-Sparrow | | +| `max_bird_crops` | Lincolns-Sparrow | | +| `wildlife_model_mode` | Lincolns-Sparrow | Allowlist values changed at Gambels-Quail (`balanced` no longer valid → coerced to `accurate`). Dropped by Kentucky-Warbler's stricter sanitizer. | +| `detector_name` | Lincolns-Sparrow | Allowlist changed at Gambels-Quail (`mdv5a` → `mdv6-c`/`mdv6-e`). Dropped by Kentucky-Warbler. At v2.0.2, legacy `mdv6-e` values are silently remapped to `mdv1000-cedar` (`_migrate_legacy_detector_name`); allowlist becomes `{mdv5a, mdv1000-cedar}`. | +| `exposure_quality` | Lincolns-Sparrow | Dropped by Kentucky-Warbler (which used `_ALLOWED_EXPOSURE_PROFILES` instead). | +| `thumbnail_max_width` | Lincolns-Sparrow | Dropped by Kentucky-Warbler's strict sanitizer. | +| `thumbnail_jpeg_quality` | Lincolns-Sparrow | Dropped by Kentucky-Warbler's strict sanitizer. | +| `raw_preview_cache_enabled` | Lincolns-Sparrow | | +| `kestrel_impact_total_files` | Lincolns-Sparrow | Monotonic counter, preserved everywhere. | +| `kestrel_impact_total_seconds` | Lincolns-Sparrow | Monotonic counter, preserved everywhere. | +| `future_feature_xyz` | Lincolns-Sparrow | Forward-compat probe — preserved by Lincolns/Willow (no sanitizer) and Gambels-Quail (explicit passthrough). Dropped by Kentucky-Warbler. | +| `future_unknown_key` | Lincolns-Sparrow | Same as above. | + +## Monotonic Counters + +| Counter | First Seen | Type | Guard Tested? | +|----------------------------------|-------------------|--------|---------------| +| `kestrel_impact_total_files` | Lincolns-Sparrow | int | yes | +| `kestrel_impact_total_seconds` | Lincolns-Sparrow | float | yes | + +Both counters are preserved across every kept fixture, including the +strict-sanitizer `Kentucky-Warbler` fixture and the `_minimal` variant. + +## Forward-Compat Passthrough + +| Tag | Future keys preserved? | Mechanism | +|----------------------|------------------------|-----------| +| Lincolns-Sparrow | yes | No sanitizer — payload written verbatim. | +| Willow-Ptarmigan | yes | No sanitizer. | +| Willow-Ptarmigan-F1 | yes | No sanitizer. | +| Kentucky-Warbler | no | Strict allowlist — unknown keys dropped silently. | +| Gambels-Quail | yes | Explicit `_PASSTHROUGH_MAX_*` rules (size-limited but type-agnostic). | + +This is the reason `settings_v_Gambels-Quail_with_future_keys.json` exists as +a separate file: `TestForwardCompatibility.test_with_future_keys_fixture` in +`tests/compat/test_settings_migration.py` filters fixtures by filename +substring and asserts that every key in those fixtures survives the current +sanitizer. + +## Failed Tags + +None of the 5 tags with `settings_utils.py` failed to capture. The 12 earlier +tags are skipped because the settings module did not exist yet, not because +of a runtime failure. + +## How tests use these fixtures + +`analyzer/tests/compat/test_settings_migration.py` parametrizes across every +`settings_v_*.json` fixture and verifies that the current `_sanitize_settings_payload`: + +1. Does not crash on any historical key-set. +2. Does not drop any key present in the fixture (forward-compat). +3. Does not regress monotonic counters. + +The `_with_future_keys` variant additionally asserts that explicitly-unknown +keys survive sanitization end-to-end. diff --git a/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail.json b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail.json new file mode 100644 index 00000000..9aa16eda --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail.json @@ -0,0 +1,18 @@ +{ + "detection_threshold": 0.27, + "detector_name": "mdv6-e", + "editor": "darktable", + "exposure_quality": "balanced", + "future_feature_xyz": true, + "future_unknown_key": "hello", + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, + "mask_threshold": 0.55, + "max_bird_crops": 5, + "rating_profile": "balanced", + "raw_preview_cache_enabled": true, + "scene_time_threshold": 60.0, + "thumbnail_jpeg_quality": 75, + "thumbnail_max_width": 1200, + "wildlife_model_mode": "accurate" +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_minimal.json b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_minimal.json new file mode 100644 index 00000000..4d99407d --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_minimal.json @@ -0,0 +1,4 @@ +{ + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5 +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_with_future_keys.json b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_with_future_keys.json new file mode 100644 index 00000000..9aa16eda --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/settings_v_Gambels-Quail_with_future_keys.json @@ -0,0 +1,18 @@ +{ + "detection_threshold": 0.27, + "detector_name": "mdv6-e", + "editor": "darktable", + "exposure_quality": "balanced", + "future_feature_xyz": true, + "future_unknown_key": "hello", + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, + "mask_threshold": 0.55, + "max_bird_crops": 5, + "rating_profile": "balanced", + "raw_preview_cache_enabled": true, + "scene_time_threshold": 60.0, + "thumbnail_jpeg_quality": 75, + "thumbnail_max_width": 1200, + "wildlife_model_mode": "accurate" +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_settings/settings_v_Kentucky-Warbler.json b/analyzer/tests/fixtures/legacy_settings/settings_v_Kentucky-Warbler.json new file mode 100644 index 00000000..bf48488f --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/settings_v_Kentucky-Warbler.json @@ -0,0 +1,11 @@ +{ + "detection_threshold": 0.27, + "editor": "darktable", + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, + "mask_threshold": 0.55, + "max_bird_crops": 5, + "rating_profile": "balanced", + "raw_preview_cache_enabled": true, + "scene_time_threshold": 60.0 +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/legacy_settings/settings_v_Lincolns-Sparrow.json b/analyzer/tests/fixtures/legacy_settings/settings_v_Lincolns-Sparrow.json new file mode 100644 index 00000000..cb021a4e --- /dev/null +++ b/analyzer/tests/fixtures/legacy_settings/settings_v_Lincolns-Sparrow.json @@ -0,0 +1,18 @@ +{ + "detection_threshold": 0.27, + "detector_name": "mdv5a", + "editor": "/Applications/Adobe Lightroom Classic.app", + "exposure_quality": "balanced", + "future_feature_xyz": true, + "future_unknown_key": "hello", + "kestrel_impact_total_files": 15000, + "kestrel_impact_total_seconds": 9500.5, + "mask_threshold": 0.55, + "max_bird_crops": 5, + "rating_profile": "balanced", + "raw_preview_cache_enabled": true, + "scene_time_threshold": 1500, + "thumbnail_jpeg_quality": 75, + "thumbnail_max_width": 1200, + "wildlife_model_mode": "balanced" +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/malicious_scenedata/.kestrel/kestrel_scenedata.json b/analyzer/tests/fixtures/malicious_scenedata/.kestrel/kestrel_scenedata.json new file mode 100644 index 00000000..17514459 --- /dev/null +++ b/analyzer/tests/fixtures/malicious_scenedata/.kestrel/kestrel_scenedata.json @@ -0,0 +1,21 @@ +{ + "version": 2, + "scenes": [ + { + "id": "1", + "name": "FINDING-01 XSS FIRED via scene name — patch is NOT in place');\">" + }, + { + "id": "2", + "name": "" + }, + { + "id": "3", + "name": "" + }, + { + "id": "4", + "name": "Normal scene name — if you see this text verbatim (not interpreted as HTML), the fix is holding." + } + ] +} diff --git a/analyzer/tests/fixtures/malicious_scenedata/README.md b/analyzer/tests/fixtures/malicious_scenedata/README.md new file mode 100644 index 00000000..605cb1eb --- /dev/null +++ b/analyzer/tests/fixtures/malicious_scenedata/README.md @@ -0,0 +1,80 @@ +# FINDING-01 end-to-end fixture + +This fixture lets you confirm the stored-XSS fix end-to-end inside the real +pywebview build, without DevTools and without running anything that could +actually execute code on the host. + +## What it contains + +`.kestrel/kestrel_scenedata.json` — four scenes. The first three are attack +payloads; the fourth is a plain-text control. + +| Scene | Technique | What it does when rendered unsafely | +| ----- | ------------------- | ------------------------------------------------------------------------ | +| #1 | `` | Native `alert()` pops, then paints a giant red "XSS FIRED" banner. | +| #2 | `` | Native `alert()` pops. | +| #3 | `" }, + { "id": "0:2", "name": "" }, + { "id": "0:3", "name": "Normal scene name — should show angle brackets literally" } + ] + } + ``` + +5. Re-open the folder in Kestrel. + +## Pass / fail criteria + +| Signal | Vulnerable | Fixed | +| --------------------------------------------------- | ---------------------------- | -------------------------- | +| Native alert dialog appears on scene render | **YES** (up to 3 dialogs) | NO | +| Page background turns red / "XSS FIRED" banner | **YES** | NO | +| Scene #4 title shows `<` and `>` verbatim as text | NO (they're gone, tag parsed)| **YES** (visible brackets) | +| `document.title` becomes altered | possibly | NO | + +If a dialog pops, stop — the patch isn't in place (or there's a second sink +somewhere else in the render path). The Python unit suite under +`analyzer/tests/` will point at the exact line. + +## Clean-up + +After testing, either: + +- Restore the real scene names: Kestrel's "Reset Culling Decisions" / + rename flow will persist a new `kestrel_scenedata.json`, OR +- Delete `/.kestrel/kestrel_scenedata.json` entirely; the app will + regenerate an empty one next time scenes are named. + +## Safety + +Every payload here is inert beyond visible UI effects: no `open_url`, no +`fetch`, no file system access, no navigation. Safe to run on any +workstation. The red banner is cosmetic — reload the folder to clear it. diff --git a/test_imgs/IMG_2014.CR3 b/analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_2014.CR3 similarity index 100% rename from test_imgs/IMG_2014.CR3 rename to analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_2014.CR3 diff --git a/test_imgs/IMG_2015.CR3 b/analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_2015.CR3 similarity index 100% rename from test_imgs/IMG_2015.CR3 rename to analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_2015.CR3 diff --git a/test_imgs/IMG_3067.CR3 b/analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_3067.CR3 similarity index 100% rename from test_imgs/IMG_3067.CR3 rename to analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_3067.CR3 diff --git a/test_imgs/IMG_3068.CR3 b/analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_3068.CR3 similarity index 100% rename from test_imgs/IMG_3068.CR3 rename to analyzer/tests/fixtures/test_sets/set_a_fresh/IMG_3068.CR3 diff --git a/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_2269.CR2 b/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_2269.CR2 new file mode 100644 index 00000000..fa50d509 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_2269.CR2 differ diff --git a/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_8667.CR3 b/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_8667.CR3 new file mode 100644 index 00000000..616432e0 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_b_formats/IMG_8667.CR3 differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_2013_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_2013_crop_0.jpg new file mode 100644 index 00000000..cd2615d9 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_2013_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3069_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3069_crop_0.jpg new file mode 100644 index 00000000..5853b1d6 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3069_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3360_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3360_crop_0.jpg new file mode 100644 index 00000000..3dee380b Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_3360_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7368_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7368_crop_0.jpg new file mode 100644 index 00000000..d700931b Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7368_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7369_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7369_crop_0.jpg new file mode 100644 index 00000000..cb2497c5 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/crop/IMG_7369_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_2013_export.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_2013_export.jpg new file mode 100644 index 00000000..08f415de Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_2013_export.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3069_export.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3069_export.jpg new file mode 100644 index 00000000..886b4882 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3069_export.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3360_export.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3360_export.jpg new file mode 100644 index 00000000..9d183055 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_3360_export.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7368_export.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7368_export.jpg new file mode 100644 index 00000000..f39b6c94 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7368_export.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7369_export.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7369_export.jpg new file mode 100644 index 00000000..db0d19e4 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/IMG_7369_export.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_crop_0.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_crop_0.jpg new file mode 100644 index 00000000..35c51216 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_crop_0.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_overlay.jpg b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_overlay.jpg new file mode 100644 index 00000000..4d1d181d Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/export/__live_overlay.jpg differ diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_database.csv b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_database.csv new file mode 100644 index 00000000..7c1008ce --- /dev/null +++ b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_database.csv @@ -0,0 +1,6 @@ +filename,species,species_confidence,family,family_confidence,quality,export_path,crop_path,crops_json,primary_crop_index,scene_count,feature_similarity,feature_confidence,color_similarity,color_confidence,similar,secondary_species_list,secondary_species_scores,secondary_family_list,secondary_family_scores,exposure_correction,exposure_pipeline,exposure_subject_stops,exposure_meter_scale,detection_scores,capture_time,orientation +IMG_2013.CR3,Chipping Sparrow,0.7142840027809143,Sparrow sp.,0.8216139078140259,0.7474707770012342,.kestrel\export\IMG_2013_export.jpg,.kestrel\crop\IMG_2013_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel\\crop\\IMG_2013_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9657063484191895, ""species"": ""Chipping Sparrow"", ""species_confidence"": 0.7142840027809143, ""family"": ""Sparrow sp."", ""family_confidence"": 0.8216139078140259, ""quality"": 0.7474707770012342, ""rating"": 4, ""exposure_correction"": 0.915, ""exposure_pipeline"": ""numpy_linear_v2"", ""exposure_subject_stops"": -0.5025, ""exposure_meter_scale"": 2.671152, ""bbox"": {""x_min"": 2816, ""x_max"": 4850, ""y_min"": 1304, ""y_max"": 3338, ""width"": 2034, ""height"": 2034, ""x_min_norm"": 0.4032073310423826, ""x_max_norm"": 0.6944444444444444, ""y_min_norm"": 0.2798283261802575, ""y_max_norm"": 0.7163090128755365, ""x_center_norm"": 0.5488258877434136, ""y_center_norm"": 0.498068669527897}}]",0,1,-1,-1,-1,-1,False,"[""Chipping Sparrow""]",[0.7142840027809143],"[""Sparrow sp.""]",[0.8216139078140259],0.915,numpy_linear_v2,-0.5025,2.671152,[0.9657063484191895],2025-05-29T07:49:10,landscape +IMG_3069.CR3,Yellow Warbler,0.6220396757125854,Warbler sp.,0.9269545674324036,0.35391261369484805,.kestrel\export\IMG_3069_export.jpg,.kestrel\crop\IMG_3069_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel\\crop\\IMG_3069_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.9186017341068862, ""species"": ""Yellow Warbler"", ""species_confidence"": 0.6220396757125854, ""family"": ""Warbler sp."", ""family_confidence"": 0.9269545674324036, ""quality"": 0.35391261369484805, ""rating"": 2, ""exposure_correction"": 0.7667, ""exposure_pipeline"": ""numpy_linear_v2"", ""exposure_subject_stops"": 0.4532, ""exposure_meter_scale"": 1.242702, ""bbox"": {""x_min"": 4572, ""x_max"": 5392, ""y_min"": 1318, ""y_max"": 2138, ""width"": 820, ""height"": 820, ""x_min_norm"": 0.654639175257732, ""x_max_norm"": 0.7720504009163803, ""y_min_norm"": 0.28283261802575105, ""y_max_norm"": 0.45879828326180255, ""x_center_norm"": 0.7133447880870561, ""y_center_norm"": 0.3708154506437768}}]",0,2,0.0,1.0,0,0,False,"[""Yellow Warbler""]",[0.6220396757125854],"[""Warbler sp.""]",[0.9269545674324036],0.7667,numpy_linear_v2,0.4532,1.242702,[0.9186017341068862],2025-05-30T05:55:35,landscape +IMG_3360.CR3,Yellow-headed Blackbird,0.9927741885185242,Blackbird/Oriole sp.,0.9948431849479675,0.26850849744657934,.kestrel\export\IMG_3360_export.jpg,.kestrel\crop\IMG_3360_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel\\crop\\IMG_3360_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.6855009482475509, ""species"": ""Yellow-headed Blackbird"", ""species_confidence"": 0.9927741885185242, ""family"": ""Blackbird/Oriole sp."", ""family_confidence"": 0.9948431849479675, ""quality"": 0.26850849744657934, ""rating"": 2, ""exposure_correction"": 0.4174, ""exposure_pipeline"": ""numpy_linear_v2"", ""exposure_subject_stops"": -0.1795, ""exposure_meter_scale"": 1.512468, ""bbox"": {""x_min"": 3876, ""x_max"": 4988, ""y_min"": 2090, ""y_max"": 3202, ""width"": 1112, ""height"": 1112, ""x_min_norm"": 0.5549828178694158, ""x_max_norm"": 0.7142038946162658, ""y_min_norm"": 0.44849785407725323, ""y_max_norm"": 0.6871244635193133, ""x_center_norm"": 0.6345933562428407, ""y_center_norm"": 0.5678111587982833}}]",0,3,0.0,1.0,0,0,False,"[""Yellow-headed Blackbird""]",[0.9927741885185242],"[""Blackbird/Oriole sp.""]",[0.9948431849479675],0.4174,numpy_linear_v2,-0.1795,1.512468,[0.6855009482475509],2025-05-30T09:22:11,landscape +IMG_7368.CR3,White-winged Dove,0.9641246795654297,Pigeon/Dove sp.,0.9750667810440063,0.553418780760023,.kestrel\export\IMG_7368_export.jpg,.kestrel\crop\IMG_7368_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel\\crop\\IMG_7368_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.6903024911880493, ""species"": ""White-winged Dove"", ""species_confidence"": 0.9641246795654297, ""family"": ""Pigeon/Dove sp."", ""family_confidence"": 0.9750667810440063, ""quality"": 0.553418780760023, ""rating"": 3, ""exposure_correction"": 0.1408, ""exposure_pipeline"": ""numpy_linear_v2"", ""exposure_subject_stops"": 0.0, ""exposure_meter_scale"": 1.102514, ""bbox"": {""x_min"": 2327, ""x_max"": 5015, ""y_min"": 1104, ""y_max"": 3792, ""width"": 2688, ""height"": 2688, ""x_min_norm"": 0.3331901489117984, ""x_max_norm"": 0.718069873997709, ""y_min_norm"": 0.2369098712446352, ""y_max_norm"": 0.8137339055793992, ""x_center_norm"": 0.5256300114547537, ""y_center_norm"": 0.5253218884120172}}]",0,4,0.0,1.0,0,0,False,"[""White-winged Dove""]",[0.9641246795654297],"[""Pigeon/Dove sp.""]",[0.9750667810440063],0.1408,numpy_linear_v2,0.0,1.102514,[0.6903024911880493],2025-08-02T12:01:37,landscape +IMG_7369.CR3,White-winged Dove,0.973678708076477,Pigeon/Dove sp.,0.9831644892692566,0.511956230900754,.kestrel\export\IMG_7369_export.jpg,.kestrel\crop\IMG_7369_crop_0.jpg,"[{""crop_index"": 0, ""crop_path"": "".kestrel\\crop\\IMG_7369_crop_0.jpg"", ""detection_index"": 0, ""detection_confidence"": 0.8954997576558379, ""species"": ""White-winged Dove"", ""species_confidence"": 0.973678708076477, ""family"": ""Pigeon/Dove sp."", ""family_confidence"": 0.9831644892692566, ""quality"": 0.511956230900754, ""rating"": 3, ""exposure_correction"": 0.1591, ""exposure_pipeline"": ""numpy_linear_v2"", ""exposure_subject_stops"": 0.0, ""exposure_meter_scale"": 1.116561, ""bbox"": {""x_min"": 2342, ""x_max"": 4972, ""y_min"": 1249, ""y_max"": 3879, ""width"": 2630, ""height"": 2630, ""x_min_norm"": 0.3353379152348224, ""x_max_norm"": 0.7119129438717068, ""y_min_norm"": 0.2680257510729614, ""y_max_norm"": 0.8324034334763949, ""x_center_norm"": 0.5236254295532646, ""y_center_norm"": 0.5502145922746781}}]",0,4,-1.0,-1.0,-1.0,-1.0,True,"[""White-winged Dove""]",[0.973678708076477],"[""Pigeon/Dove sp.""]",[0.9831644892692566],0.1591,numpy_linear_v2,0.0,1.116561,[0.8954997576558379],2025-08-02T12:01:37,landscape diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_error_20260511T034508Z.json b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_error_20260511T034508Z.json new file mode 100644 index 00000000..466f5993 --- /dev/null +++ b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_error_20260511T034508Z.json @@ -0,0 +1,45 @@ +[ + { + "timestamp_utc": "2026-05-11T03:45:08.819825Z", + "level": "info", + "event": "analysis_start", + "analyzer": "visualizer-queue", + "folder": "C:\\Data\\Code\\Project Kestrel\\ProjectKestrel\\analyzer\\tests\\fixtures\\test_sets\\set_c_preanalyzed", + "file_count": 5, + "detector_name": "mdv5a", + "detection_threshold": 0.15, + "parallel_prefetch": 3 + }, + { + "timestamp_utc": "2026-05-11T03:45:33.405767Z", + "level": "warning", + "stage": "save_database", + "context": { + "file": "IMG_2013.CR3", + "folder": "C:\\Data\\Code\\Project Kestrel\\ProjectKestrel\\analyzer\\tests\\fixtures\\test_sets\\set_c_preanalyzed" + }, + "message": "The behavior of DataFrame concatenation with empty or all-NA entries is deprecated. In a future version, this will no longer exclude empty or all-NA columns when determining the result dtypes. To retain the old behavior, exclude the relevant entries before the concat operation.", + "category": "FutureWarning", + "filename": "C:\\Data\\Code\\Project Kestrel\\ProjectKestrel\\analyzer\\kestrel_analyzer\\pipeline.py", + "lineno": 1305 + }, + { + "timestamp_utc": "2026-05-11T03:45:47.254228Z", + "level": "info", + "event": "decode_queue_summary", + "max_workers": 3, + "file_count": 5, + "peak_inflight": 4, + "sum_decode_ms": 49424.0, + "sum_wait_ms": 10735.0, + "avg_decode_ms": 9884.8, + "avg_wait_ms": 2147.0 + }, + { + "timestamp_utc": "2026-05-11T03:45:47.316992Z", + "level": "info", + "event": "analysis_complete", + "folder": "C:\\Data\\Code\\Project Kestrel\\ProjectKestrel\\analyzer\\tests\\fixtures\\test_sets\\set_c_preanalyzed", + "total_files": 5 + } +] \ No newline at end of file diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_metadata.json b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_metadata.json new file mode 100644 index 00000000..7c5d38bb --- /dev/null +++ b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_metadata.json @@ -0,0 +1,132 @@ +{ + "version": "2.0.1", + "analyzer": "visualizer-queue", + "created_utc": "2026-05-11T03:45:08.852956Z", + "database_file": "kestrel_database.csv", + "quality_distribution": [ + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 1, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0, + 0 + ], + "quality_distribution_stored": true, + "exposure_pipeline_version": 3, + "exposure_render_mode": "numpy_linear_v2", + "exposure_quality": "balanced", + "analyzed_utc": "2026-05-11T03:45:47.313256Z", + "kestrel_version": "2.0.1", + "analysis_settings": { + "detector_name": "mdv5a", + "use_gpu": true, + "wildlife_enabled": false, + "species_detection_enabled": true, + "detection_threshold": 0.15, + "scene_time_threshold": 2.0, + "mask_threshold": 0.5, + "max_bird_crops": 5, + "parallel_prefetch": 3, + "rating_profile": "balanced", + "exposure_quality": "balanced", + "exposure_render_mode": "numpy_linear_v2", + "exposure_pipeline_version": 3, + "thumbnail_max_width": 1200, + "thumbnail_jpeg_compression": 0.75, + "thumbnail_jpeg_quality": 75 + } +} \ No newline at end of file diff --git a/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_scenedata.json b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_scenedata.json new file mode 100644 index 00000000..f0b43e2a --- /dev/null +++ b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/.kestrel/kestrel_scenedata.json @@ -0,0 +1,59 @@ +{ + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": [ + "IMG_2013.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "2": { + "scene_id": "2", + "image_filenames": [ + "IMG_3069.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "3": { + "scene_id": "3", + "image_filenames": [ + "IMG_3360.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + }, + "4": { + "scene_id": "4", + "image_filenames": [ + "IMG_7368.CR3", + "IMG_7369.CR3" + ], + "name": "", + "status": "pending", + "user_tags": { + "species": [], + "families": [], + "finalized": false + } + } + } +} \ No newline at end of file diff --git a/test_imgs/IMG_2013.CR3 b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_2013.CR3 similarity index 100% rename from test_imgs/IMG_2013.CR3 rename to analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_2013.CR3 diff --git a/test_imgs/IMG_3069.CR3 b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_3069.CR3 similarity index 100% rename from test_imgs/IMG_3069.CR3 rename to analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_3069.CR3 diff --git a/test_imgs/IMG_3360.CR3 b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_3360.CR3 similarity index 100% rename from test_imgs/IMG_3360.CR3 rename to analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_3360.CR3 diff --git a/test_imgs/IMG_7368.CR3 b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_7368.CR3 similarity index 100% rename from test_imgs/IMG_7368.CR3 rename to analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_7368.CR3 diff --git a/test_imgs/IMG_7369.CR3 b/analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_7369.CR3 similarity index 100% rename from test_imgs/IMG_7369.CR3 rename to analyzer/tests/fixtures/test_sets/set_c_preanalyzed/IMG_7369.CR3 diff --git a/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2255.JPG b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2255.JPG new file mode 100644 index 00000000..6488701f Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2255.JPG differ diff --git a/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2256.JPG b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2256.JPG new file mode 100644 index 00000000..2e4e4180 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2256.JPG differ diff --git a/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2257.JPG b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2257.JPG new file mode 100644 index 00000000..29e455a3 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2257.JPG differ diff --git a/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2258.JPG b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2258.JPG new file mode 100644 index 00000000..c60a504b Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_d_jpeg_only/IMG_2258.JPG differ diff --git a/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.CR2 b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.CR2 new file mode 100644 index 00000000..8e010804 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.CR2 differ diff --git a/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.JPG b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.JPG new file mode 100644 index 00000000..79762c97 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2265.JPG differ diff --git a/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.CR2 b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.CR2 new file mode 100644 index 00000000..bbcded17 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.CR2 differ diff --git a/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.JPG b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.JPG new file mode 100644 index 00000000..a1abc6b7 Binary files /dev/null and b/analyzer/tests/fixtures/test_sets/set_e_raw_jpg_mix/IMG_2266.JPG differ diff --git a/analyzer/tests/integration/__init__.py b/analyzer/tests/integration/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/integration/test_bird_classifier.py b/analyzer/tests/integration/test_bird_classifier.py new file mode 100644 index 00000000..7871f0c0 --- /dev/null +++ b/analyzer/tests/integration/test_bird_classifier.py @@ -0,0 +1,120 @@ +"""Integration tests for BirdSpeciesClassifier (custom ONNX bird model). + +Loads model.onnx + labels.txt and verifies it classifies a real bird crop +plus synthetic inputs. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import ( + MODELS_DIR, + SPECIESCLASSIFIER_LABELS, + SPECIESCLASSIFIER_PATH, +) +from kestrel_analyzer.image_utils import read_image +from kestrel_analyzer.ml.bird_species import BirdSpeciesClassifier +from kestrel_analyzer.ml.provider_coordinator import ( + ProviderCoordinator, + ResilienceConfig, +) + + +pytestmark = pytest.mark.integration + + +_skip_no_model = pytest.mark.skipif( + not (Path(SPECIESCLASSIFIER_PATH).is_file() and Path(SPECIESCLASSIFIER_LABELS).is_file()), + reason="Bird classifier model.onnx or labels.txt not present", +) + + +@pytest.fixture(scope="module") +def bird_classifier(): + if not (Path(SPECIESCLASSIFIER_PATH).is_file() and Path(SPECIESCLASSIFIER_LABELS).is_file()): + pytest.skip("Bird classifier weights missing") + coord = ProviderCoordinator( + user_gpu_enabled=False, + cfg=ResilienceConfig(), + ) + return BirdSpeciesClassifier( + str(SPECIESCLASSIFIER_PATH), + str(SPECIESCLASSIFIER_LABELS), + coord, + models_dir=str(MODELS_DIR), + ) + + +@_skip_no_model +class TestBirdClassifierLoading: + def test_classifier_loads(self, bird_classifier): + assert bird_classifier is not None + assert bird_classifier.session is not None + + def test_labels_loaded(self, bird_classifier): + assert len(bird_classifier.labels) > 100 # Bird taxonomy has thousands + + def test_family_matrix_built(self, bird_classifier): + # If species-to-family mapping CSVs exist, family_matrix should be populated. + assert bird_classifier.family_matrix.ndim == 2 + assert bird_classifier.family_matrix.shape[1] == len(bird_classifier.labels) + + +@_skip_no_model +class TestBirdClassifierInference: + def test_classify_returns_expected_keys(self, bird_classifier): + img = np.full((300, 300, 3), 128, dtype=np.uint8) + result = bird_classifier.classify(img, top_k=5) + + assert "top_species_labels" in result + assert "top_species_scores" in result + assert "top_family_labels" in result + assert "top_family_scores" in result + + def test_top_k_respected(self, bird_classifier): + img = np.full((300, 300, 3), 128, dtype=np.uint8) + result = bird_classifier.classify(img, top_k=3) + assert len(result["top_species_labels"]) == 3 + assert len(result["top_species_scores"]) == 3 + + def test_top_species_labels_are_strings(self, bird_classifier): + img = np.full((300, 300, 3), 128, dtype=np.uint8) + result = bird_classifier.classify(img, top_k=5) + for label in result["top_species_labels"]: + assert isinstance(str(label), str) + assert len(str(label)) > 0 + + def test_classify_on_real_image(self, bird_classifier, set_a_path): + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 fixtures") + img = read_image(str(cr3_files[0])) + if img is None: + pytest.skip("Could not decode CR3") + + # Crop centre square so we don't run a 6000-px image through a 300x300 classifier + h, w = img.shape[:2] + s = min(h, w) + cy, cx = h // 2, w // 2 + crop = img[cy - s // 2: cy + s // 2, cx - s // 2: cx + s // 2] + + result = bird_classifier.classify(crop, top_k=5) + assert len(result["top_species_labels"]) == 5 + + def test_repeatable_scores(self, bird_classifier): + img = np.full((300, 300, 3), 80, dtype=np.uint8) + r1 = bird_classifier.classify(img, top_k=5) + r2 = bird_classifier.classify(img, top_k=5) + assert list(r1["top_species_labels"]) == list(r2["top_species_labels"]) + np.testing.assert_allclose( + np.array(r1["top_species_scores"]), + np.array(r2["top_species_scores"]), + rtol=1e-4, + ) diff --git a/analyzer/tests/integration/test_exif_read.py b/analyzer/tests/integration/test_exif_read.py new file mode 100644 index 00000000..f9373e3b --- /dev/null +++ b/analyzer/tests/integration/test_exif_read.py @@ -0,0 +1,133 @@ +"""Integration tests for EXIF reading from real RAW files. + +Uses fixtures in set_a_fresh/ (CR3) and set_b_formats/ (CR2, CR3, NEF, ARW, DNG). +""" + +import pytest +from datetime import datetime +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.raw_exif import get_capture_time + + +pytestmark = pytest.mark.integration + + +class TestGetCaptureTime: + """Tests for raw_exif.get_capture_time() across formats.""" + + def test_cr3_returns_datetime(self, set_a_path): + """CR3 from set_a → returns datetime.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + result = get_capture_time(str(cr3_files[0])) + assert isinstance(result, datetime) + + def test_cr3_year_reasonable(self, set_a_path): + """CR3 capture time year is plausible (2000-2100).""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + result = get_capture_time(str(cr3_files[0])) + assert 2000 < result.year < 2100 + + def test_get_capture_time_is_repeatable(self, set_a_path): + """Two consecutive reads return same value.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + result1 = get_capture_time(str(cr3_files[0])) + result2 = get_capture_time(str(cr3_files[0])) + assert result1 == result2 + + def test_invalid_file_raises_error(self, tmp_path): + """Binary garbage file → raises ValueError or RuntimeError.""" + bad_file = tmp_path / "garbage.cr3" + bad_file.write_bytes(b"\x00\x01\x02\x03 not a real raw file") + + with pytest.raises((ValueError, RuntimeError, Exception)): + get_capture_time(str(bad_file)) + + def test_nonexistent_file_raises_error(self, tmp_path): + """Nonexistent file → raises error.""" + with pytest.raises(Exception): + get_capture_time(str(tmp_path / "nonexistent.cr3")) + + +class TestMultipleFormats: + """Tests across diverse RAW formats in set_b_formats/.""" + + def test_all_set_b_formats_readable(self, set_b_paths): + """Every format in set_b returns a valid datetime.""" + if not set_b_paths: + pytest.skip("No set_b fixtures present") + + results = {} + for ext, path in set_b_paths.items(): + try: + result = get_capture_time(str(path)) + results[ext] = result + assert isinstance(result, datetime), f"{ext} failed: not a datetime" + except Exception as e: + pytest.fail(f"Failed to read EXIF from {ext}: {e}") + + # At least one format should have worked + assert len(results) > 0 + + def test_jpeg_capture_time(self, set_d_path): + """JPEG files have EXIF readable.""" + if not set_d_path.exists(): + pytest.skip(f"Fixture {set_d_path} not present") + + jpg_files = list(set_d_path.glob("*.JPG")) + if not jpg_files: + pytest.skip("No JPGs in set_d") + + result = get_capture_time(str(jpg_files[0])) + assert isinstance(result, datetime) + + +class TestSceneTiming: + """Tests for set_a scene timing - should have 2 distinct scenes.""" + + def test_set_a_has_4_images(self, set_a_path): + """set_a_fresh should have 4 images for scene grouping tests.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + assert len(cr3_files) >= 4, f"Expected 4+ CR3 files, got {len(cr3_files)}" + + def test_set_a_timestamps_readable(self, set_a_path): + """All 4 images in set_a have readable timestamps.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = sorted(set_a_path.glob("*.CR3")) + if len(cr3_files) < 4: + pytest.skip(f"Need 4 CR3 files, got {len(cr3_files)}") + + times = [] + for f in cr3_files[:4]: + t = get_capture_time(str(f)) + assert isinstance(t, datetime) + times.append(t) + + # All times should be valid datetimes (sanity check) + assert all(isinstance(t, datetime) for t in times) diff --git a/analyzer/tests/integration/test_megadetector.py b/analyzer/tests/integration/test_megadetector.py new file mode 100644 index 00000000..cfeacef2 --- /dev/null +++ b/analyzer/tests/integration/test_megadetector.py @@ -0,0 +1,156 @@ +"""Integration tests for MegaDetector (via SpeciesNetSAMHQWrapper). + +Parametrized over every detector in ``DETECTOR_ONNX_PATHS`` (currently +``mdv5a`` and ``mdv1000-cedar``). Each test runs once per detector whose +ONNX weights are present; missing weights are cleanly skipped. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest +from PIL import Image + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import DETECTOR_ONNX_PATHS +from kestrel_analyzer.image_utils import read_image +from kestrel_analyzer.ml.speciesnet_sam_hq import SpeciesNetSAMHQWrapper + + +pytestmark = pytest.mark.integration + + +_ALL_DETECTORS = list(DETECTOR_ONNX_PATHS.keys()) + + +@pytest.fixture(scope="module", params=_ALL_DETECTORS) +def detector_wrapper(request): + """Module-scoped wrapper with detector + classifier loaded once per detector model. + + Parametrized over every detector defined in ``DETECTOR_ONNX_PATHS`` — each + test runs once per detector. Skips cleanly if the weights for the current + parameter aren't present. + """ + detector_name = request.param + weights = DETECTOR_ONNX_PATHS[detector_name] + if not weights.is_file(): + pytest.skip(f"Detector weights for '{detector_name}' not present: {weights}") + wrapper = SpeciesNetSAMHQWrapper( + max_bird_crops=5, + use_gpu=False, # CPU is sufficient and reliable across CI runners + detector_name=detector_name, + ) + wrapper._ensure_speciesnet() + return wrapper + + +class TestMegaDetectorLoading: + """Verify each detector loads without exceptions.""" + + def test_detector_loads(self, detector_wrapper): + assert detector_wrapper.detector is not None + + def test_classifier_loads(self, detector_wrapper): + assert detector_wrapper.classifier is not None + + def test_detector_has_predict_method(self, detector_wrapper): + assert callable(getattr(detector_wrapper.detector, "predict", None)) + assert callable(getattr(detector_wrapper.detector, "preprocess", None)) + + def test_detector_name_recorded_on_wrapper(self, detector_wrapper): + assert detector_wrapper.detector_name in _ALL_DETECTORS + + +class TestMegaDetectorInference: + """Run each detector against real fixtures and validate output shape.""" + + def _first_cr3(self, set_a_path): + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + return cr3_files[0] + + def test_detector_returns_dict_with_detections(self, detector_wrapper, set_a_path): + cr3 = self._first_cr3(set_a_path) + rgb = read_image(str(cr3)) + if rgb is None: + pytest.skip(f"Could not decode {cr3}") + + img_pil = Image.fromarray(rgb) + det_input = detector_wrapper.detector.preprocess(img_pil) + result = detector_wrapper.detector.predict(str(cr3), det_input) + + assert isinstance(result, dict) + assert "detections" in result + assert isinstance(result["detections"], list) + + def test_detection_entries_have_expected_keys(self, detector_wrapper, set_a_path): + cr3 = self._first_cr3(set_a_path) + rgb = read_image(str(cr3)) + if rgb is None: + pytest.skip(f"Could not decode {cr3}") + + img_pil = Image.fromarray(rgb) + det_input = detector_wrapper.detector.preprocess(img_pil) + result = detector_wrapper.detector.predict(str(cr3), det_input) + + for det in result["detections"]: + assert "label" in det + assert "conf" in det + assert "bbox" in det + assert det["label"] in ("animal", "person", "vehicle", "unknown") + assert 0.0 <= float(det["conf"]) <= 1.0 + assert len(det["bbox"]) == 4 + + def test_detection_bboxes_normalized(self, detector_wrapper, set_a_path): + """Every bbox should be normalized to [0,1] (xmin, ymin, width, height).""" + cr3 = self._first_cr3(set_a_path) + rgb = read_image(str(cr3)) + if rgb is None: + pytest.skip(f"Could not decode {cr3}") + + img_pil = Image.fromarray(rgb) + det_input = detector_wrapper.detector.preprocess(img_pil) + result = detector_wrapper.detector.predict(str(cr3), det_input) + + for det in result["detections"]: + xmin, ymin, w, h = det["bbox"] + assert -0.01 <= xmin <= 1.01 + assert -0.01 <= ymin <= 1.01 + assert 0.0 <= w <= 1.01 + assert 0.0 <= h <= 1.01 + + def test_blank_image_yields_no_high_conf_detections(self, detector_wrapper): + """A pure-white image should not produce confident animal detections.""" + blank = np.full((640, 640, 3), 255, dtype=np.uint8) + img_pil = Image.fromarray(blank) + det_input = detector_wrapper.detector.preprocess(img_pil) + result = detector_wrapper.detector.predict("blank.jpg", det_input) + + high_conf = [d for d in result["detections"] + if float(d.get("conf", 0.0)) >= 0.5 + and str(d.get("label")) == "animal"] + assert len(high_conf) == 0 + + def test_detector_repeatable(self, detector_wrapper, set_a_path): + """Running the same image through detector twice yields identical detections.""" + cr3 = self._first_cr3(set_a_path) + rgb = read_image(str(cr3)) + if rgb is None: + pytest.skip(f"Could not decode {cr3}") + + img_pil = Image.fromarray(rgb) + det_input_1 = detector_wrapper.detector.preprocess(img_pil) + result_1 = detector_wrapper.detector.predict(str(cr3), det_input_1) + + det_input_2 = detector_wrapper.detector.preprocess(img_pil) + result_2 = detector_wrapper.detector.predict(str(cr3), det_input_2) + + assert len(result_1["detections"]) == len(result_2["detections"]) + for a, b in zip(result_1["detections"], result_2["detections"]): + assert a["label"] == b["label"] + assert float(a["conf"]) == pytest.approx(float(b["conf"]), abs=1e-4) diff --git a/analyzer/tests/integration/test_pipeline_e2e.py b/analyzer/tests/integration/test_pipeline_e2e.py new file mode 100644 index 00000000..c715d80d --- /dev/null +++ b/analyzer/tests/integration/test_pipeline_e2e.py @@ -0,0 +1,255 @@ +"""End-to-end pipeline test: AnalysisPipeline.process_folder() on real fixtures. + +Copies set_a_fresh/ to a tmp_path and runs the full pipeline. Verifies the +.kestrel/ directory is created, the database has rows for each input image, +scenedata is correct, and key columns are populated. Tagged @e2e in addition +to @integration because this is the slow, full-stack test. +""" + +import json +import os +import shutil +import sys +from pathlib import Path + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import ( + DATABASE_NAME, + DEFAULT_DETECTOR_NAME, + DETECTOR_ONNX_PATHS, + KESTREL_DIR_NAME, + QUALITYCLASSIFIER_PATH, + SAM_DEC_ONNX_PATH, + SAM_ENC_ONNX_PATH, + SCENEDATA_FILENAME, + SPECIESCLASSIFIER_PATH, + SPECIESNET_MODEL_DIR, +) +from kestrel_analyzer.database import BASE_COLUMNS, load_database, load_scenedata +from kestrel_analyzer.pipeline import AnalysisPipeline + + +pytestmark = [pytest.mark.integration, pytest.mark.e2e] + + +_REQUIRED_MODELS = [ + DETECTOR_ONNX_PATHS[DEFAULT_DETECTOR_NAME], + SPECIESNET_MODEL_DIR / "speciesNet_v4.0.1a.onnx", + Path(SAM_ENC_ONNX_PATH), + Path(SAM_DEC_ONNX_PATH), + Path(SPECIESCLASSIFIER_PATH), + Path(QUALITYCLASSIFIER_PATH), +] + +_skip_no_models = pytest.mark.skipif( + not all(p.is_file() for p in _REQUIRED_MODELS), + reason="Required ML model files missing for E2E pipeline", +) + + +@pytest.fixture +def cr3_workdir(tmp_path, set_a_path): + """Copy set_a_fresh/ into a writable temp dir.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if len(cr3_files) < 2: + pytest.skip("Need at least 2 CR3 fixtures in set_a_fresh") + + work = tmp_path / "set_a" + work.mkdir() + for f in cr3_files: + shutil.copy2(f, work / f.name) + return work + + +@pytest.fixture +def jpeg_workdir(tmp_path, set_d_path): + if not set_d_path.exists(): + pytest.skip(f"Fixture {set_d_path} not present") + jpgs = sorted(set_d_path.glob("*.JPG")) + if len(jpgs) < 2: + pytest.skip("Need at least 2 JPEG fixtures in set_d") + + work = tmp_path / "set_d" + work.mkdir() + for f in jpgs: + shutil.copy2(f, work / f.name) + return work + + +@pytest.fixture +def raw_jpg_mix_workdir(tmp_path, set_e_path): + if not set_e_path.exists(): + pytest.skip(f"Fixture {set_e_path} not present") + work = tmp_path / "set_e" + work.mkdir() + files = sorted(list(set_e_path.glob("*.CR2")) + list(set_e_path.glob("*.JPG"))) + if not files: + pytest.skip("Need fixtures in set_e_raw_jpg_mix") + for f in files: + shutil.copy2(f, work / f.name) + return work + + +@_skip_no_models +class TestPipelineE2EOnCR3: + """Full pipeline on real CR3 wildlife images.""" + + @pytest.fixture(scope="class") + def pipeline_result_dir(self, request, tmp_path_factory): + """Run the pipeline once for this test class, cache the result dir.""" + set_a = ( + Path(__file__).parent.parent / "fixtures" / "test_sets" / "set_a_fresh" + ) + if not set_a.exists(): + pytest.skip(f"Fixture {set_a} not present") + cr3_files = sorted(set_a.glob("*.CR3")) + if len(cr3_files) < 2: + pytest.skip("Need at least 2 CR3 fixtures") + + work = tmp_path_factory.mktemp("pipeline_e2e") + for f in cr3_files: + shutil.copy2(f, work / f.name) + + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + pipeline.process_folder( + folder=str(work), + analyzer_name="pytest_e2e", + wildlife_enabled=True, + species_detection_enabled=True, + detection_threshold=0.25, + scene_time_threshold=60.0, + max_bird_crops=5, + parallel_prefetch=1, + ) + return work, len(cr3_files) + + def test_kestrel_dir_created(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = work / KESTREL_DIR_NAME + assert kestrel_dir.is_dir() + assert (kestrel_dir / "export").is_dir() + assert (kestrel_dir / "crop").is_dir() + + def test_database_csv_exists(self, pipeline_result_dir): + work, _ = pipeline_result_dir + csv_path = work / KESTREL_DIR_NAME / DATABASE_NAME + assert csv_path.is_file() + assert csv_path.stat().st_size > 0 + + def test_database_has_row_per_image(self, pipeline_result_dir): + work, n_files = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + assert len(db) == n_files + + def test_database_columns_match_schema(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + for col in BASE_COLUMNS: + assert col in db.columns, f"Missing column: {col}" + + def test_filenames_recorded(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + disk_files = {f.name for f in work.glob("*.CR3")} + db_files = set(db["filename"].astype(str)) + assert disk_files == db_files + + def test_capture_time_populated(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + # capture_time should be non-empty for real CR3 images with EXIF + non_empty = db["capture_time"].astype(str).str.len() > 0 + assert non_empty.all(), "Some rows have empty capture_time" + + def test_scenedata_json_exists(self, pipeline_result_dir): + work, _ = pipeline_result_dir + scenedata_path = work / KESTREL_DIR_NAME / SCENEDATA_FILENAME + assert scenedata_path.is_file() + data = json.loads(scenedata_path.read_text(encoding="utf-8")) + assert "scenes" in data or "version" in data # current schema + + def test_scene_grouping_present(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + # Every row should have a non-null scene_count + assert db["scene_count"].notna().all() + # Scene counts should be small positive integers + scenes = db["scene_count"].astype(int) + assert (scenes >= 1).all() + + def test_exposure_correction_populated(self, pipeline_result_dir): + work, _ = pipeline_result_dir + kestrel_dir = str(work / KESTREL_DIR_NAME) + db, _ = load_database(kestrel_dir, "pytest_e2e") + ec = db["exposure_correction"].dropna() + assert len(ec) == len(db), "Some rows missing exposure_correction" + + def test_export_thumbnails_created(self, pipeline_result_dir): + work, _ = pipeline_result_dir + export_dir = work / KESTREL_DIR_NAME / "export" + thumbs = list(export_dir.glob("*.jpg")) + assert len(thumbs) >= 1, "Pipeline produced no export thumbnails" + + +@_skip_no_models +class TestPipelineE2EOnJPEG: + """Pipeline should also handle JPEG-only folders.""" + + def test_process_jpeg_only_folder(self, jpeg_workdir): + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + pipeline.process_folder( + folder=str(jpeg_workdir), + analyzer_name="pytest_e2e_jpeg", + wildlife_enabled=True, + species_detection_enabled=True, + detection_threshold=0.25, + scene_time_threshold=60.0, + max_bird_crops=5, + parallel_prefetch=1, + ) + kestrel_dir = jpeg_workdir / KESTREL_DIR_NAME + assert kestrel_dir.is_dir() + csv_path = kestrel_dir / DATABASE_NAME + assert csv_path.is_file() + + db, _ = load_database(str(kestrel_dir), "pytest_e2e_jpeg") + disk_jpgs = {f.name for f in jpeg_workdir.glob("*.JPG")} + assert len(db) == len(disk_jpgs) + + +@_skip_no_models +class TestPipelineE2ERAWPreferredOverJPEG: + """When both RAW and JPEG are present, the pipeline picks RAW only.""" + + def test_raw_preferred(self, raw_jpg_mix_workdir): + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + pipeline.process_folder( + folder=str(raw_jpg_mix_workdir), + analyzer_name="pytest_e2e_mix", + wildlife_enabled=True, + species_detection_enabled=True, + detection_threshold=0.25, + scene_time_threshold=60.0, + max_bird_crops=5, + parallel_prefetch=1, + ) + kestrel_dir = raw_jpg_mix_workdir / KESTREL_DIR_NAME + db, _ = load_database(str(kestrel_dir), "pytest_e2e_mix") + # Pipeline should pick RAW exclusively when both are present + cr2_count = len(list(raw_jpg_mix_workdir.glob("*.CR2"))) + assert len(db) == cr2_count, ( + f"Expected {cr2_count} RAW rows, got {len(db)}: {list(db['filename'])}" + ) + # No row's filename should end in .jpg + for fn in db["filename"].astype(str): + assert not fn.lower().endswith(".jpg"), f"Got JPEG row when RAW present: {fn}" diff --git a/analyzer/tests/integration/test_quality_classifier.py b/analyzer/tests/integration/test_quality_classifier.py new file mode 100644 index 00000000..ae1cd38f --- /dev/null +++ b/analyzer/tests/integration/test_quality_classifier.py @@ -0,0 +1,111 @@ +"""Integration tests for QualityClassifier (custom ONNX quality model). + +Loads quality.onnx + normalization data and runs inference on synthetic +and real crops. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import ( + QUALITY_NORMALIZATION_DATA_PATH, + QUALITYCLASSIFIER_PATH, +) +from kestrel_analyzer.image_utils import read_image +from kestrel_analyzer.ml.provider_coordinator import ( + ProviderCoordinator, + ResilienceConfig, +) +from kestrel_analyzer.ml.quality import QualityClassifier + + +pytestmark = pytest.mark.integration + + +_skip_no_model = pytest.mark.skipif( + not Path(QUALITYCLASSIFIER_PATH).is_file(), + reason="Quality classifier quality.onnx not present", +) + + +@pytest.fixture(scope="module") +def quality_classifier(): + if not Path(QUALITYCLASSIFIER_PATH).is_file(): + pytest.skip("Quality classifier weights missing") + coord = ProviderCoordinator( + user_gpu_enabled=False, + cfg=ResilienceConfig(), + ) + return QualityClassifier( + str(QUALITYCLASSIFIER_PATH), + normalization_data_path=str(QUALITY_NORMALIZATION_DATA_PATH) + if Path(QUALITY_NORMALIZATION_DATA_PATH).is_file() else None, + coord=coord, + ) + + +@_skip_no_model +class TestQualityClassifierLoading: + def test_classifier_loads(self, quality_classifier): + assert quality_classifier is not None + assert quality_classifier.session is not None + + def test_input_name_resolved(self, quality_classifier): + assert isinstance(quality_classifier._input_name, str) + assert len(quality_classifier._input_name) > 0 + + +@_skip_no_model +class TestQualityClassifierInference: + def test_classify_returns_float(self, quality_classifier): + img = np.full((512, 512, 3), 128, dtype=np.uint8) + mask = np.ones((512, 512), dtype=np.uint8) + result = quality_classifier.classify(img, mask) + assert isinstance(result, float) + + def test_score_in_unit_range_or_error_sentinel(self, quality_classifier): + """Quality returns a normalized percentile in [0,1] or -1.0 on error.""" + img = np.full((512, 512, 3), 128, dtype=np.uint8) + mask = np.ones((512, 512), dtype=np.uint8) + score = quality_classifier.classify(img, mask) + assert score == -1.0 or (0.0 <= score <= 1.0) + + def test_repeatable_score_same_input(self, quality_classifier): + img = np.full((512, 512, 3), 128, dtype=np.uint8) + mask = np.ones((512, 512), dtype=np.uint8) + a = quality_classifier.classify(img, mask) + b = quality_classifier.classify(img, mask) + assert a == pytest.approx(b, abs=1e-5) + + def test_classify_real_image_crop(self, quality_classifier, set_a_path): + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 fixtures") + img = read_image(str(cr3_files[0])) + if img is None: + pytest.skip("Could not decode CR3") + + h, w = img.shape[:2] + s = min(h, w) + cy, cx = h // 2, w // 2 + crop = img[cy - s // 2: cy + s // 2, cx - s // 2: cx + s // 2] + mask = np.ones(crop.shape[:2], dtype=np.uint8) + + score = quality_classifier.classify(crop, mask) + assert score == -1.0 or (0.0 <= score <= 1.0) + + def test_zero_mask_handled_gracefully(self, quality_classifier): + """An empty mask should not crash — classifier returns -1.0 sentinel on error + or a valid number, never throws.""" + img = np.full((512, 512, 3), 128, dtype=np.uint8) + mask = np.zeros((512, 512), dtype=np.uint8) + result = quality_classifier.classify(img, mask) + # Either a finite quality or the documented -1.0 sentinel + assert isinstance(result, float) diff --git a/analyzer/tests/integration/test_raw_decode.py b/analyzer/tests/integration/test_raw_decode.py new file mode 100644 index 00000000..e2449f90 --- /dev/null +++ b/analyzer/tests/integration/test_raw_decode.py @@ -0,0 +1,207 @@ +"""Integration tests for RAW image decoding via image_utils.py. + +Uses fixtures in set_a_fresh/ (CR3), set_b_formats/ (diverse RAW), and set_d_jpeg_only/ (JPEG). +""" + +import pytest +import numpy as np +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.image_utils import read_image, read_image_for_pipeline +from kestrel_analyzer.exposure_compensation import build_metered_detection_image + + +pytestmark = pytest.mark.integration + + +class TestReadImage: + """Tests for read_image() across RAW and JPEG formats.""" + + def test_cr3_decodes_to_rgb_array(self, set_a_path): + """CR3 from set_a → returns RGB array (H, W, 3).""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + img = read_image(str(cr3_files[0])) + assert img is not None + assert img.ndim == 3 + assert img.shape[2] == 3 # RGB + assert img.dtype == np.uint8 + + def test_cr3_dimensions_plausible(self, set_a_path): + """Decoded CR3 has reasonable resolution (not tiny, not absurdly large).""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + img = read_image(str(cr3_files[0])) + H, W = img.shape[:2] + # Camera RAW images should be at least a few MP + assert H > 1000 and W > 1000 + # And not absurdly huge + assert H < 20000 and W < 20000 + + def test_jpeg_decodes(self, set_d_path): + """JPEG file → returns RGB array.""" + if not set_d_path.exists(): + pytest.skip(f"Fixture {set_d_path} not present") + + jpg_files = list(set_d_path.glob("*.JPG")) + if not jpg_files: + pytest.skip("No JPG files in set_d") + + img = read_image(str(jpg_files[0])) + assert img is not None + assert img.ndim == 3 + assert img.shape[2] == 3 + + def test_invalid_file_returns_none(self, tmp_path): + """Garbage file → returns None (no crash).""" + bad_file = tmp_path / "garbage.cr3" + bad_file.write_bytes(b"\x00" * 1000) + + result = read_image(str(bad_file)) + assert result is None + + def test_nonexistent_file_returns_none(self, tmp_path): + """Nonexistent file → returns None.""" + result = read_image(str(tmp_path / "nonexistent.cr3")) + assert result is None + + +class TestReadImageForPipeline: + """Tests for read_image_for_pipeline() — keeps RawPy open for re-use.""" + + def test_cr3_returns_none_and_raw_obj(self, set_a_path): + """CR3 → returns (None, RawPy object).""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + rgb, raw_obj = read_image_for_pipeline(str(cr3_files[0])) + try: + assert rgb is None # First element is None for RAW + assert raw_obj is not None + finally: + if raw_obj is not None: + raw_obj.close() + + def test_jpeg_returns_array_and_none(self, set_d_path): + """JPEG → returns (array, None).""" + if not set_d_path.exists(): + pytest.skip(f"Fixture {set_d_path} not present") + + jpg_files = list(set_d_path.glob("*.JPG")) + if not jpg_files: + pytest.skip("No JPG files in set_d") + + rgb, raw_obj = read_image_for_pipeline(str(jpg_files[0])) + assert rgb is not None + assert raw_obj is None + assert rgb.ndim == 3 + assert rgb.shape[2] == 3 + + def test_invalid_file_returns_none_none(self, tmp_path): + """Invalid RAW → (None, None).""" + bad_file = tmp_path / "garbage.cr3" + bad_file.write_bytes(b"\x00" * 1000) + + rgb, raw_obj = read_image_for_pipeline(str(bad_file)) + assert rgb is None + assert raw_obj is None + + +class TestDiverseRAWFormats: + """Tests across all RAW formats in set_b_formats/.""" + + def test_all_formats_decode(self, set_b_paths): + """Every fixture in set_b_formats successfully decodes.""" + if not set_b_paths: + pytest.skip("No set_b_formats fixtures") + + for ext, path in set_b_paths.items(): + img = read_image(str(path)) + assert img is not None, f"Failed to decode {ext}: {path}" + assert img.ndim == 3, f"{ext}: wrong shape" + assert img.shape[2] == 3, f"{ext}: not RGB" + + +class TestBuildMeteredDetectionImage: + """Tests for build_metered_detection_image() — pipeline-level RAW decode.""" + + def test_cr3_returns_expected_tuple(self, set_a_path): + """CR3 → (metered8, meter_scale, debug_dict, noauto_linear) 4-tuple.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + _, raw_obj = read_image_for_pipeline(str(cr3_files[0])) + if raw_obj is None: + pytest.skip("Could not open RAW file") + + try: + metered8, meter_scale, debug, noauto_linear = build_metered_detection_image(raw_obj) + assert metered8 is not None + assert metered8.dtype == np.uint8 + assert metered8.shape[2] == 3 # RGB + assert isinstance(meter_scale, float) + assert isinstance(debug, dict) + assert noauto_linear is not None + assert noauto_linear.dtype == np.float32 + finally: + raw_obj.close() + + def test_meter_scale_in_valid_range(self, set_a_path): + """meter_scale is clamped to [0.25, 8.0].""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + _, raw_obj = read_image_for_pipeline(str(cr3_files[0])) + if raw_obj is None: + pytest.skip("Could not open RAW file") + + try: + _, meter_scale, _, _ = build_metered_detection_image(raw_obj) + assert 0.25 <= meter_scale <= 8.0 + finally: + raw_obj.close() + + def test_noauto_linear_in_zero_one_range(self, set_a_path): + """noauto_linear float32 array is in [0, 1] range.""" + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + + cr3_files = list(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 files in set_a_fresh") + + _, raw_obj = read_image_for_pipeline(str(cr3_files[0])) + if raw_obj is None: + pytest.skip("Could not open RAW file") + + try: + _, _, _, noauto_linear = build_metered_detection_image(raw_obj) + assert noauto_linear.min() >= 0.0 + assert noauto_linear.max() <= 1.0 + finally: + raw_obj.close() diff --git a/analyzer/tests/integration/test_sam_hq.py b/analyzer/tests/integration/test_sam_hq.py new file mode 100644 index 00000000..67494271 --- /dev/null +++ b/analyzer/tests/integration/test_sam_hq.py @@ -0,0 +1,162 @@ +"""Integration tests for SAM-HQ ViT-Tiny ONNX encoder/decoder via OnnxSamPredictor. + +Loads the real SAM-HQ encoder + decoder ONNX files and runs encode + box-prompt +decode on a real CR3 image, plus a synthetic image where ground truth is known. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import SAM_DEC_ONNX_PATH, SAM_ENC_ONNX_PATH +from kestrel_analyzer.image_utils import read_image +from kestrel_analyzer.ml.provider_coordinator import ( + ProviderCoordinator, + ResilienceConfig, +) +from kestrel_analyzer.ml.speciesnet_sam_hq import OnnxSamPredictor + + +pytestmark = pytest.mark.integration + + +_skip_no_sam = pytest.mark.skipif( + not (Path(SAM_ENC_ONNX_PATH).is_file() and Path(SAM_DEC_ONNX_PATH).is_file()), + reason="SAM-HQ encoder/decoder ONNX weights not present", +) + + +@pytest.fixture(scope="module") +def sam_predictor(): + if not (Path(SAM_ENC_ONNX_PATH).is_file() and Path(SAM_DEC_ONNX_PATH).is_file()): + pytest.skip("SAM-HQ ONNX weights missing") + coord = ProviderCoordinator( + user_gpu_enabled=False, + cfg=ResilienceConfig(), + ) + return OnnxSamPredictor(SAM_ENC_ONNX_PATH, SAM_DEC_ONNX_PATH, coord) + + +@_skip_no_sam +class TestSamLoading: + def test_predictor_initialises(self, sam_predictor): + assert sam_predictor is not None + assert sam_predictor._enc_session is not None + assert sam_predictor._dec_session is not None + + +@_skip_no_sam +class TestSamEncoder: + """Verify encoder output shapes match what the decoder will consume.""" + + def test_encode_on_synthetic_image(self, sam_predictor): + img = np.full((512, 512, 3), 127, dtype=np.uint8) + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + + assert emb is not None + assert interm is not None + assert isinstance(resized_hw, tuple) and len(resized_hw) == 2 + assert isinstance(orig_hw, tuple) and len(orig_hw) == 2 + assert orig_hw == (512, 512) + assert emb.ndim >= 3 + + def test_encode_on_real_cr3(self, sam_predictor, set_a_path): + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 fixtures") + img = read_image(str(cr3_files[0])) + if img is None: + pytest.skip("Could not decode CR3") + + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + assert orig_hw == (img.shape[0], img.shape[1]) + assert emb.shape[0] == 1 # batch=1 + + +@_skip_no_sam +class TestSamDecoder: + """Verify decode_box returns a mask of the right shape and IoU range.""" + + def test_decode_box_yields_mask_of_original_size(self, sam_predictor): + img = np.zeros((400, 600, 3), dtype=np.uint8) + # Draw a bright rectangle in the middle so SAM has something to latch onto + img[100:300, 200:400] = 200 + + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + mask, iou = sam_predictor.decode_box( + emb, interm, (200, 100, 400, 300), resized_hw, orig_hw + ) + + assert mask.dtype == bool + assert mask.shape == (400, 600) + assert 0.0 <= iou <= 1.0 + + def test_decode_box_mask_overlaps_prompt_region(self, sam_predictor): + """The returned mask should have most of its True pixels inside the prompt box.""" + img = np.zeros((400, 600, 3), dtype=np.uint8) + img[100:300, 200:400] = 200 + + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + mask, _ = sam_predictor.decode_box( + emb, interm, (200, 100, 400, 300), resized_hw, orig_hw + ) + + total = int(mask.sum()) + if total == 0: + pytest.skip("SAM returned empty mask for this synthetic prompt") + + inside_box = int(mask[100:300, 200:400].sum()) + # At least 50% of the mask pixels should be inside the prompt box. + assert inside_box / total >= 0.5 + + def test_decode_box_on_real_image(self, sam_predictor, set_a_path): + if not set_a_path.exists(): + pytest.skip(f"Fixture {set_a_path} not present") + cr3_files = sorted(set_a_path.glob("*.CR3")) + if not cr3_files: + pytest.skip("No CR3 fixtures") + img = read_image(str(cr3_files[0])) + if img is None: + pytest.skip("Could not decode CR3") + + h, w = img.shape[:2] + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + # Use a centre-ish bounding box + box = (w // 4, h // 4, 3 * w // 4, 3 * h // 4) + mask, iou = sam_predictor.decode_box(emb, interm, box, resized_hw, orig_hw) + + assert mask.shape == (h, w) + assert mask.dtype == bool + assert 0.0 <= iou <= 1.0 + + +@_skip_no_sam +class TestSamDecodeBoxes: + """Batched decode_boxes() should behave equivalently to per-box decode.""" + + def test_decode_boxes_returns_one_result_per_box(self, sam_predictor): + img = np.full((400, 600, 3), 127, dtype=np.uint8) + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + + boxes = [ + (50, 50, 200, 200), + (300, 100, 500, 300), + ] + results = sam_predictor.decode_boxes(emb, interm, boxes, resized_hw, orig_hw) + assert len(results) == len(boxes) + for mask, iou in results: + assert mask.shape == (400, 600) + assert mask.dtype == bool + assert 0.0 <= iou <= 1.0 + + def test_decode_boxes_empty_list_returns_empty(self, sam_predictor): + img = np.full((400, 600, 3), 127, dtype=np.uint8) + emb, interm, resized_hw, orig_hw = sam_predictor.encode(img) + results = sam_predictor.decode_boxes(emb, interm, [], resized_hw, orig_hw) + assert results == [] diff --git a/analyzer/tests/integration/test_speciesnet.py b/analyzer/tests/integration/test_speciesnet.py new file mode 100644 index 00000000..def8ce5e --- /dev/null +++ b/analyzer/tests/integration/test_speciesnet.py @@ -0,0 +1,173 @@ +"""Integration tests for the full SpeciesNet + SAM-HQ wrapper (`get_prediction`). + +Loads the real detector + classifier + SAM-HQ + ensemble and runs end-to-end +prediction on CR3 fixtures in set_a_fresh/. +""" + +import sys +from pathlib import Path + +import numpy as np +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.config import ( + DEFAULT_DETECTOR_NAME, + DETECTOR_ONNX_PATHS, + SAM_DEC_ONNX_PATH, + SAM_ENC_ONNX_PATH, + SPECIESNET_MODEL_DIR, +) +from kestrel_analyzer.image_utils import read_image +from kestrel_analyzer.ml.speciesnet_sam_hq import SpeciesNetSAMHQWrapper + + +pytestmark = pytest.mark.integration + + +_DETECTOR_PATH = DETECTOR_ONNX_PATHS[DEFAULT_DETECTOR_NAME] +_SPECIESNET_ONNX = SPECIESNET_MODEL_DIR / "speciesNet_v4.0.1a.onnx" + +_skip_no_models = pytest.mark.skipif( + not ( + _DETECTOR_PATH.is_file() + and _SPECIESNET_ONNX.is_file() + and Path(SAM_ENC_ONNX_PATH).is_file() + and Path(SAM_DEC_ONNX_PATH).is_file() + ), + reason="One or more SpeciesNet / SAM-HQ ONNX weights are missing", +) + + +@pytest.fixture(scope="module") +def loaded_wrapper(): + if not ( + _DETECTOR_PATH.is_file() + and _SPECIESNET_ONNX.is_file() + and Path(SAM_ENC_ONNX_PATH).is_file() + and Path(SAM_DEC_ONNX_PATH).is_file() + ): + pytest.skip("Required SpeciesNet / SAM-HQ weights missing") + wrapper = SpeciesNetSAMHQWrapper( + max_bird_crops=5, + use_gpu=False, + detector_name=DEFAULT_DETECTOR_NAME, + ) + wrapper.ensure_loaded() + return wrapper + + +@pytest.fixture(scope="module") +def first_cr3(request): + set_a = ( + Path(__file__).parent.parent / "fixtures" / "test_sets" / "set_a_fresh" + ) + if not set_a.exists(): + pytest.skip(f"Fixture {set_a} not present") + files = sorted(set_a.glob("*.CR3")) + if not files: + pytest.skip("No CR3 fixtures in set_a_fresh") + return files[0] + + +@pytest.fixture(scope="module") +def first_cr3_decoded(first_cr3): + img = read_image(str(first_cr3)) + if img is None: + pytest.skip(f"Could not decode {first_cr3}") + return img + + +@_skip_no_models +class TestSpeciesNetLoading: + def test_ensure_loaded_succeeds(self, loaded_wrapper): + assert loaded_wrapper.detector is not None + assert loaded_wrapper.classifier is not None + assert loaded_wrapper.predictor is not None + assert loaded_wrapper.ensemble is not None + + def test_classifier_has_labels(self, loaded_wrapper): + labels = loaded_wrapper.classifier._labels + assert isinstance(labels, list) + assert len(labels) > 100 # SpeciesNet has thousands of labels + + +@_skip_no_models +class TestSpeciesNetPrediction: + """End-to-end get_prediction() output shape and value-range tests.""" + + def test_get_prediction_returns_4_tuple(self, loaded_wrapper, first_cr3, first_cr3_decoded): + result = loaded_wrapper.get_prediction( + first_cr3_decoded, + first_cr3, + wildlife_enabled=True, + threshold=0.25, + ) + assert isinstance(result, tuple) + assert len(result) == 4 + + def test_prediction_arrays_same_length(self, loaded_wrapper, first_cr3, first_cr3_decoded): + masks, pred_boxes, pred_class, pred_score = loaded_wrapper.get_prediction( + first_cr3_decoded, + first_cr3, + wildlife_enabled=True, + threshold=0.25, + ) + # masks may be empty list or a stacked array; the three label lists must align + assert len(pred_boxes) == len(pred_class) == len(pred_score) + if pred_class: + # masks is a stacked numpy array when non-empty + assert hasattr(masks, "shape") and masks.shape[0] == len(pred_class) + + def test_pred_classes_valid(self, loaded_wrapper, first_cr3, first_cr3_decoded): + _, _, pred_class, _ = loaded_wrapper.get_prediction( + first_cr3_decoded, + first_cr3, + wildlife_enabled=True, + threshold=0.25, + ) + for cls in pred_class: + # After routing, only "bird" or a wildlife species label remains + assert isinstance(cls, str) + assert cls != "" + + def test_pred_scores_in_unit_range(self, loaded_wrapper, first_cr3, first_cr3_decoded): + _, _, _, pred_score = loaded_wrapper.get_prediction( + first_cr3_decoded, + first_cr3, + wildlife_enabled=True, + threshold=0.25, + ) + for s in pred_score: + assert 0.0 <= float(s) <= 1.0 + + def test_high_threshold_yields_fewer_detections(self, loaded_wrapper, first_cr3, first_cr3_decoded): + _, _, _, low = loaded_wrapper.get_prediction( + first_cr3_decoded, first_cr3, wildlife_enabled=True, threshold=0.10 + ) + _, _, _, high = loaded_wrapper.get_prediction( + first_cr3_decoded, first_cr3, wildlife_enabled=True, threshold=0.99 + ) + assert len(high) <= len(low) + + def test_blank_image_no_detections(self, loaded_wrapper): + """Pure-white synthetic image → no confident detections.""" + blank = np.full((1024, 1024, 3), 255, dtype=np.uint8) + masks, pred_boxes, pred_class, pred_score = loaded_wrapper.get_prediction( + blank, "blank.jpg", wildlife_enabled=True, threshold=0.5 + ) + assert len(pred_class) == 0 + assert len(pred_boxes) == 0 + assert len(pred_score) == 0 + + def test_wildlife_disabled_excludes_non_bird(self, loaded_wrapper, first_cr3, first_cr3_decoded): + _, _, pred_class, _ = loaded_wrapper.get_prediction( + first_cr3_decoded, + first_cr3, + wildlife_enabled=False, + threshold=0.25, + ) + # With wildlife disabled, only "bird" labels survive routing + for cls in pred_class: + assert cls == "bird", f"Got non-bird '{cls}' with wildlife_enabled=False" diff --git a/analyzer/tests/pytest.ini b/analyzer/tests/pytest.ini new file mode 100644 index 00000000..669842da --- /dev/null +++ b/analyzer/tests/pytest.ini @@ -0,0 +1,8 @@ +[pytest] +testpaths = . +markers = + unit: Fast tests, no ML models, no real images needed + integration: Needs ML model weights in analyzer/models/ + e2e: End-to-end pipeline, slow + compat: Backwards compatibility migration tests + ui: Launches the full application diff --git a/analyzer/tests/security/__init__.py b/analyzer/tests/security/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/test_security_open_url.py b/analyzer/tests/test_security_open_url.py new file mode 100644 index 00000000..e403a992 --- /dev/null +++ b/analyzer/tests/test_security_open_url.py @@ -0,0 +1,209 @@ +"""Regression tests for FINDING-01: RCE via ``Api.open_url``. + +Background +---------- +The XSS sink in ``visualizer.js`` (sceneName rendered via +``decodeEntities(escapeHtml(...))`` into ``.innerHTML``) lets attacker JS run +in the webview's context, from which it can call any method on +``window.pywebview.api``. ``Api.open_url`` forwarded its argument directly to +``webbrowser.open``; on Windows that falls through to ``os.startfile`` / +``ShellExecute``, allowing ``file:///C:/.../calc.exe``, UNC paths, ``.lnk`` +files, etc. to be executed. + +Expected fix +------------ +``api_bridge`` must expose a pure predicate ``_is_safe_external_url(url)`` +that accepts only http(s) / mailto URLs with no embedded control characters, +no backslashes, and no UNC prefixes. ``Api.open_url`` must call the predicate +and refuse to forward unsafe URLs to ``webbrowser.open``. + +If the helper is not yet defined, ``TestOpenUrlAllowlist`` is skipped with a +message pointing at the fix location, so the suite can be committed before +the patch lands. + +Run with:: + + cd analyzer + python -m unittest tests.test_security_open_url +""" + +from __future__ import annotations + +import os +import sys +import unittest + + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ANALYZER_DIR = os.path.dirname(_THIS_DIR) +if _ANALYZER_DIR not in sys.path: + sys.path.insert(0, _ANALYZER_DIR) + + +try: + import api_bridge # noqa: E402 + _IMPORT_ERROR: Exception | None = None +except Exception as exc: # pragma: no cover — surface import errors as skip + api_bridge = None # type: ignore[assignment] + _IMPORT_ERROR = exc + + +_HELPER_AVAILABLE = api_bridge is not None and hasattr(api_bridge, "_is_safe_external_url") +_SKIP_MSG = ( + "api_bridge._is_safe_external_url is not defined — apply the FINDING-01 " + "URL-allowlist patch to analyzer/api_bridge.py, then re-run." +) + + +@unittest.skipUnless(api_bridge is not None, f"api_bridge import failed: {_IMPORT_ERROR}") +@unittest.skipUnless(_HELPER_AVAILABLE, _SKIP_MSG) +class TestOpenUrlAllowlist(unittest.TestCase): + """Unit-level tests of the ``_is_safe_external_url`` predicate.""" + + # ---- Allowed ---- + def test_https_is_allowed(self) -> None: + self.assertTrue(api_bridge._is_safe_external_url("https://projectkestrel.org/")) + self.assertTrue( + api_bridge._is_safe_external_url("https://projectkestrel.org/download") + ) + self.assertTrue( + api_bridge._is_safe_external_url("https://github.com/owner/repo/issues/1") + ) + + def test_http_is_allowed(self) -> None: + self.assertTrue( + api_bridge._is_safe_external_url("http://127.0.0.1:8765/health") + ) + + def test_mailto_is_allowed(self) -> None: + self.assertTrue( + api_bridge._is_safe_external_url("mailto:support@projectkestrel.org") + ) + + # ---- RCE vectors — must be rejected ---- + def test_file_scheme_is_rejected(self) -> None: + self.assertFalse( + api_bridge._is_safe_external_url("file:///C:/Windows/System32/calc.exe") + ) + self.assertFalse(api_bridge._is_safe_external_url("file:///etc/passwd")) + # Scheme matching must be case-insensitive + self.assertFalse( + api_bridge._is_safe_external_url("FILE:///C:/Windows/System32/calc.exe") + ) + + def test_javascript_scheme_is_rejected(self) -> None: + self.assertFalse(api_bridge._is_safe_external_url("javascript:alert(1)")) + self.assertFalse(api_bridge._is_safe_external_url("JavaScript:alert(1)")) + # Whitespace/tab obfuscation should not bypass + self.assertFalse(api_bridge._is_safe_external_url(" javascript:alert(1)")) + self.assertFalse(api_bridge._is_safe_external_url("java\tscript:alert(1)")) + + def test_vbscript_and_data_schemes_are_rejected(self) -> None: + self.assertFalse(api_bridge._is_safe_external_url("vbscript:msgbox(1)")) + self.assertFalse( + api_bridge._is_safe_external_url( + "data:text/html," + ) + ) + + def test_unc_paths_are_rejected(self) -> None: + self.assertFalse( + api_bridge._is_safe_external_url(r"\\attacker\share\payload.exe") + ) + self.assertFalse( + api_bridge._is_safe_external_url("//attacker/share/payload.exe") + ) + + def test_bare_windows_path_is_rejected(self) -> None: + self.assertFalse( + api_bridge._is_safe_external_url(r"C:\Windows\System32\calc.exe") + ) + self.assertFalse( + api_bridge._is_safe_external_url("C:/Windows/System32/calc.exe") + ) + + def test_windows_custom_uri_schemes_are_rejected(self) -> None: + for url in ( + "ms-cxh-full:", + "ms-settings:privacy", + "search-ms:displayname=x", + "shell:startup", + "ms-appinstaller:?source=https://evil.example/app.appinstaller", + ): + self.assertFalse( + api_bridge._is_safe_external_url(url), + f"Scheme should have been rejected: {url!r}", + ) + + def test_control_characters_are_rejected(self) -> None: + self.assertFalse( + api_bridge._is_safe_external_url( + "https://projectkestrel.org/\r\nX-Injected: 1" + ) + ) + self.assertFalse( + api_bridge._is_safe_external_url("https://projectkestrel.org/\x00") + ) + self.assertFalse( + api_bridge._is_safe_external_url("https://projectkestrel.org/\x1b[2J") + ) + + def test_empty_and_non_string_are_rejected(self) -> None: + self.assertFalse(api_bridge._is_safe_external_url("")) + self.assertFalse(api_bridge._is_safe_external_url(" ")) + self.assertFalse(api_bridge._is_safe_external_url(None)) # type: ignore[arg-type] + self.assertFalse(api_bridge._is_safe_external_url(123)) # type: ignore[arg-type] + + +@unittest.skipUnless(api_bridge is not None, f"api_bridge import failed: {_IMPORT_ERROR}") +class TestOpenUrlEndToEnd(unittest.TestCase): + """Verify the full ``Api.open_url`` code path refuses to dispatch dangerous + URLs to ``webbrowser.open``. We monkeypatch ``webbrowser.open`` so no real + browser/ShellExecute ever runs, even if the fix regresses.""" + + def setUp(self) -> None: + self.calls: list[str] = [] + self._orig_open = api_bridge.webbrowser.open + api_bridge.webbrowser.open = ( + lambda url, *a, **kw: (self.calls.append(url) or True) + ) + self.api = api_bridge.Api() + + def tearDown(self) -> None: + api_bridge.webbrowser.open = self._orig_open + + def test_forwards_https(self) -> None: + res = self.api.open_url("https://projectkestrel.org/") + self.assertTrue(res.get("success"), f"Expected success, got {res!r}") + self.assertEqual(self.calls, ["https://projectkestrel.org/"]) + + def test_refuses_file_scheme(self) -> None: + res = self.api.open_url("file:///C:/Windows/System32/calc.exe") + self.assertFalse( + res.get("success"), + "Api.open_url must return success=False for file:// URLs", + ) + self.assertEqual( + self.calls, + [], + "Api.open_url must NOT forward file:// URLs to webbrowser.open", + ) + + def test_refuses_unc_path(self) -> None: + res = self.api.open_url(r"\\attacker\share\evil.exe") + self.assertFalse(res.get("success")) + self.assertEqual(self.calls, []) + + def test_refuses_javascript(self) -> None: + res = self.api.open_url("javascript:alert(1)") + self.assertFalse(res.get("success")) + self.assertEqual(self.calls, []) + + def test_refuses_data_html(self) -> None: + res = self.api.open_url("data:text/html,") + self.assertFalse(res.get("success")) + self.assertEqual(self.calls, []) + + +if __name__ == "__main__": + unittest.main() diff --git a/analyzer/tests/test_security_settings_durability.py b/analyzer/tests/test_security_settings_durability.py new file mode 100644 index 00000000..c3058681 --- /dev/null +++ b/analyzer/tests/test_security_settings_durability.py @@ -0,0 +1,273 @@ +"""Regression tests for FINDING-06: settings.json data loss from silent +empty-dict fallback on corrupt load, non-atomic fallback writes, and the +dead ``_merge_forward_compatible_keys`` path. + +These tests exercise ``settings_utils.load_persisted_settings`` and +``save_persisted_settings`` against a temp directory that stands in for the +real user-data folder. They intentionally don't touch the real settings +file. + +What's guarded here +------------------- +1. The monotonic counter (``kestrel_impact_total_files``) cannot regress + across a load/save cycle, even if the caller passes a lower value. +2. If ``settings.json`` is corrupt on disk and a valid ``.bak`` exists, the + next save transparently recovers counters from ``.bak`` and quarantines + the corrupt file rather than silently writing a tiny dict over it. +3. If ``settings.json`` is corrupt AND ``.bak`` is also unusable, the save + is refused — preserving the corrupt file for manual recovery is strictly + better than clobbering data. +4. Unknown ("forward-compatible") keys survive a load/save round-trip so a + newer-build settings file loaded by an older build doesn't lose them. + +Run with:: + + cd analyzer + python -m unittest tests.test_security_settings_durability +""" + +from __future__ import annotations + +import json +import os +import sys +import tempfile +import unittest +from unittest import mock + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ANALYZER_DIR = os.path.dirname(_THIS_DIR) +if _ANALYZER_DIR not in sys.path: + sys.path.insert(0, _ANALYZER_DIR) + +import settings_utils # noqa: E402 + + +class _SettingsTempDirMixin: + """Redirect settings I/O to a per-test temp directory.""" + + def setUp(self) -> None: + self._tmp = tempfile.TemporaryDirectory(prefix="kestrel_settings_test_") + self._data_dir = self._tmp.name + self._path = os.path.join(self._data_dir, settings_utils.SETTINGS_FILENAME) + self._patch = mock.patch.object( + settings_utils, "_get_settings_path", return_value=self._path + ) + self._patch.start() + + def tearDown(self) -> None: + self._patch.stop() + self._tmp.cleanup() + + def _write_raw(self, contents: str, *, suffix: str = "") -> None: + target = self._path + suffix + with open(target, "w", encoding="utf-8") as f: + f.write(contents) + + def _read_raw(self, *, suffix: str = "") -> str: + with open(self._path + suffix, "r", encoding="utf-8") as f: + return f.read() + + +class TestMonotonicCounter(_SettingsTempDirMixin, unittest.TestCase): + """FINDING-06: the impact counter must never regress.""" + + def test_counter_clamped_up_on_regression(self) -> None: + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 500, "editor": "darktable"} + ) + # Stale caller tries to save a lower value (e.g. raced with an + # analysis that just bumped the counter, or hydrated from a cold + # localStorage). + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 100, "editor": "lightroom"} + ) + loaded = settings_utils.load_persisted_settings() + self.assertEqual( + loaded.get("kestrel_impact_total_files"), + 500, + "Counter must not regress across saves (monotonic guard)", + ) + self.assertEqual(loaded.get("editor"), "lightroom") + + def test_counter_clamped_up_on_missing_key(self) -> None: + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 1234} + ) + # Caller omits the counter entirely — must not be resurrected as 0. + settings_utils.save_persisted_settings({"editor": "darktable"}) + loaded = settings_utils.load_persisted_settings() + self.assertEqual(loaded.get("kestrel_impact_total_files"), 1234) + + def test_counter_increases_are_preserved(self) -> None: + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 10} + ) + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 25} + ) + loaded = settings_utils.load_persisted_settings() + self.assertEqual(loaded.get("kestrel_impact_total_files"), 25) + + +class TestForwardCompatKeys(_SettingsTempDirMixin, unittest.TestCase): + """FINDING-06: unknown keys survive the sanitize round-trip.""" + + def test_future_scalar_key_is_preserved(self) -> None: + self._write_raw( + json.dumps( + { + "editor": "darktable", + "kestrel_impact_total_files": 42, + "a_future_toggle": True, + "future_nested": {"ratio": 0.75, "label": "ok"}, + } + ) + ) + loaded = settings_utils.load_persisted_settings() + self.assertTrue(loaded.get("a_future_toggle")) + self.assertEqual(loaded.get("future_nested"), {"ratio": 0.75, "label": "ok"}) + + # Re-save and verify the keys still round-trip. + settings_utils.save_persisted_settings(loaded) + reloaded = settings_utils.load_persisted_settings() + self.assertTrue(reloaded.get("a_future_toggle")) + self.assertEqual( + reloaded.get("future_nested"), {"ratio": 0.75, "label": "ok"} + ) + + +class TestCorruptionHandling(_SettingsTempDirMixin, unittest.TestCase): + """FINDING-06: corrupt settings.json must not be silently clobbered.""" + + def test_recovers_counter_from_bak_after_corruption(self) -> None: + # Establish a known-good state + .bak sidecar (written by the save). + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 999, "editor": "darktable"} + ) + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 1000, "editor": "lightroom"} + ) + self.assertTrue(os.path.exists(self._path + ".bak")) + + # Corrupt the main file (simulate a bad power loss / partial write). + self._write_raw("{not valid json") + + # load_persisted_settings falls back to .bak. + loaded = settings_utils.load_persisted_settings() + self.assertIn("kestrel_impact_total_files", loaded) + self.assertGreaterEqual(loaded["kestrel_impact_total_files"], 999) + + # A save now should NOT clobber a fresh counter bump with zeros. + settings_utils.save_persisted_settings({"editor": "capture_one"}) + + # The corrupt main file should have been quarantined, a fresh file + # written, and the counter preserved from .bak. + quarantined = [ + name + for name in os.listdir(self._data_dir) + if name.startswith(settings_utils.SETTINGS_FILENAME + ".corrupt-") + ] + self.assertTrue( + quarantined, "Corrupt settings.json must be quarantined, not deleted" + ) + + reloaded = settings_utils.load_persisted_settings() + self.assertEqual(reloaded.get("editor"), "capture_one") + self.assertGreaterEqual( + reloaded.get("kestrel_impact_total_files", 0), + 999, + "Counter must survive corruption via .bak recovery", + ) + + def test_refuses_save_when_no_recovery_available(self) -> None: + # Corrupt main, NO .bak — must refuse to save to preserve evidence. + self._write_raw("{not valid json") + self.assertFalse(os.path.exists(self._path + ".bak")) + + original_bytes = self._read_raw().encode("utf-8") + + settings_utils.save_persisted_settings({"editor": "darktable"}) + + # The corrupt file must remain exactly as it was — no quarantine, + # no overwrite. The running app continues with in-memory defaults. + self.assertTrue(os.path.exists(self._path)) + self.assertEqual(self._read_raw().encode("utf-8"), original_bytes) + + def test_atomic_write_leaves_no_partial_file(self) -> None: + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 7, "editor": "gimp"} + ) + # There must be no leftover .tmp file after a successful save. + self.assertFalse(os.path.exists(self._path + ".tmp")) + # Also no orphan ``settings.json.*.tmp`` (from mkstemp naming). + import glob as _glob + orphans = _glob.glob(os.path.join(self._data_dir, "settings.json.*.tmp")) + self.assertFalse(orphans, f"Unexpected orphan tmp files: {orphans}") + + +class TestConcurrentSaves(_SettingsTempDirMixin, unittest.TestCase): + """Regression test for the WinError 2 race: multiple threads saving to + the same settings file used to collide on the shared ``settings.json.tmp`` + name, causing one side's ``os.replace`` to fail with 'cannot find the + file specified'. Each save now uses a unique ``mkstemp`` name, and the + whole save sequence is serialized by ``_SAVE_LOCK``. + """ + + def test_concurrent_saves_all_succeed_and_no_orphans(self) -> None: + import threading as _threading + import glob as _glob + + # Seed a known-good file so the monotonic guard has a baseline. + settings_utils.save_persisted_settings( + {"kestrel_impact_total_files": 1, "editor": "darktable"} + ) + + n_threads = 8 + per_thread_writes = 5 + errors: list[BaseException] = [] + errors_lock = _threading.Lock() + + def writer(tid: int) -> None: + try: + for i in range(per_thread_writes): + settings_utils.save_persisted_settings( + { + # Different counter bumps per thread so the monotonic + # guard is exercised in a race. + "kestrel_impact_total_files": tid * 100 + i, + "editor": f"editor_{tid}_{i}", + } + ) + except BaseException as exc: # noqa: BLE001 + with errors_lock: + errors.append(exc) + + threads = [ + _threading.Thread(target=writer, args=(tid,)) + for tid in range(n_threads) + ] + for t in threads: + t.start() + for t in threads: + t.join(timeout=30) + + self.assertFalse(errors, f"Concurrent saves raised: {errors}") + + # Final file must be valid JSON and a dict. + with open(self._path, "r", encoding="utf-8") as f: + final = json.load(f) + self.assertIsInstance(final, dict) + + # Monotonic guard must have preserved the maximum counter any thread + # wrote (the highest possible value is (n_threads-1)*100 + (per_thread_writes-1)). + max_possible = (n_threads - 1) * 100 + (per_thread_writes - 1) + self.assertGreaterEqual(final.get("kestrel_impact_total_files", 0), max_possible) + + # No orphan tmp files should remain. + orphans = _glob.glob(os.path.join(self._data_dir, "settings.json.*.tmp")) + self.assertFalse(orphans, f"Unexpected orphan tmp files: {orphans}") + + +if __name__ == "__main__": + unittest.main() diff --git a/analyzer/tests/test_security_visualizer_js_xss.py b/analyzer/tests/test_security_visualizer_js_xss.py new file mode 100644 index 00000000..e31520cc --- /dev/null +++ b/analyzer/tests/test_security_visualizer_js_xss.py @@ -0,0 +1,148 @@ +"""Static regression tests for FINDING-01: stored DOM-XSS via sceneName. + +These tests are deliberately written as source-level lints against +``analyzer/visualizer.js`` (and ``culling.js`` if present) so the vulnerable +pattern cannot silently re-appear. They don't require a JS runtime. + +What is forbidden +----------------- +1. ``decodeEntities(escapeHtml(...))`` anywhere — the original bug combined + these two in series, which *undoes* the escape immediately before an + ``innerHTML`` assignment. +2. ``X.innerHTML = \`...\\${...scene_name...}...\``` — user-controlled scene + names interpolated into ``.innerHTML`` template literals without an + explicit escape. After the fix, the sceneName site must use ``textContent`` + or explicit DOM construction. + +What remains permitted +---------------------- +* Building text nodes via ``document.createElement`` + ``textContent``. +* Using ``escapeHtml`` on its own (without a later ``decodeEntities``). + +Run with:: + + cd analyzer + python -m unittest tests.test_security_visualizer_js_xss +""" + +from __future__ import annotations + +import os +import re +import unittest + + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ANALYZER_DIR = os.path.dirname(_THIS_DIR) + + +def _read(path: str) -> str: + with open(path, "r", encoding="utf-8") as f: + return f.read() + + +class TestVisualizerJsXssRegression(unittest.TestCase): + VISUALIZER_JS = os.path.join(_ANALYZER_DIR, "visualizer.js") + + def setUp(self) -> None: + self.assertTrue( + os.path.exists(self.VISUALIZER_JS), + f"Missing file: {self.VISUALIZER_JS}", + ) + self.source = _read(self.VISUALIZER_JS) + + def test_no_decodeEntities_of_escapeHtml(self) -> None: + """Hard ban on the exact vulnerable pattern. Any caller that needs + to decode entities after escaping has a bug — the two operations are + inverses and the result is effectively raw HTML. + + Lines that are single-line comments (``//``) are skipped so we can + reference the forbidden pattern in prose without tripping the lint. + """ + pattern = re.compile(r"decodeEntities\s*\(\s*escapeHtml\s*\(") + hits: list[tuple[int, str]] = [] + for i, line in enumerate(self.source.splitlines()): + if not pattern.search(line): + continue + stripped = line.lstrip() + # Ignore ``// ...`` comment lines — they routinely document the + # forbidden pattern for future maintainers. + if stripped.startswith("//"): + continue + hits.append((i + 1, line)) + self.assertFalse( + hits, + "Forbidden pattern decodeEntities(escapeHtml(...)) reintroduces XSS.\n" + + "\n".join(f" visualizer.js:{ln}: {text.strip()}" for ln, text in hits), + ) + + def test_scene_name_not_interpolated_into_innerHTML(self) -> None: + """Block the broader class of the bug: any ``.innerHTML`` assignment + whose right-hand side is a template literal that references + ``sceneName`` (or ``.scene_name``) without going through a safe + wrapper. + + The permitted wrapper is ``escapeHtml(...)`` with no outer + ``decodeEntities(...)`` — that case is already guarded by + ``test_no_decodeEntities_of_escapeHtml``. We flag *any* bare + ``${...sceneName...}`` / ``${...scene_name...}`` inside an innerHTML + template literal so we catch future regressions early. + """ + # Match one logical statement: ``foo.innerHTML = `...`;`` possibly spanning + # up to a few lines. We capture the template body and scan it. + stmt_re = re.compile( + r"\.innerHTML\s*=\s*`([^`]*)`", + re.DOTALL, + ) + offenders: list[tuple[int, str]] = [] + for m in stmt_re.finditer(self.source): + body = m.group(1) + if "sceneName" not in body and "scene_name" not in body: + continue + # Inspect each ${...} expression that references sceneName/scene_name. + for expr_match in re.finditer(r"\$\{([^{}]+)\}", body): + expr = expr_match.group(1) + if "sceneName" not in expr and "scene_name" not in expr: + continue + # Permit only escapeHtml(...) with no outer decodeEntities(). + if "decodeEntities" in expr: + offenders.append(_line_info(self.source, m.start(), expr)) + continue + if "escapeHtml" not in expr: + offenders.append(_line_info(self.source, m.start(), expr)) + self.assertFalse( + offenders, + "sceneName interpolated into innerHTML without safe escaping:\n" + + "\n".join( + f" visualizer.js:{ln}: ${{{expr}}}" for ln, expr in offenders + ), + ) + + def test_decodeEntities_not_piped_into_innerHTML(self) -> None: + """``decodeEntities`` is legitimate when feeding ``textContent`` — the + browser won't parse HTML there. What's unsafe is piping its result + into ``.innerHTML`` in the same statement. Flag only those. + """ + # Scan each line (and its successor, in case the assignment wraps) + # for `.innerHTML =` AND `decodeEntities(` appearing together. + lines = self.source.splitlines() + hits: list[tuple[int, str]] = [] + for i, line in enumerate(lines): + window = line + (lines[i + 1] if i + 1 < len(lines) else "") + if ".innerHTML" in window and "decodeEntities(" in window: + hits.append((i + 1, line.strip())) + self.assertFalse( + hits, + "decodeEntities() output used in an innerHTML assignment " + "(re-enables the FINDING-01 XSS class):\n" + + "\n".join(f" visualizer.js:{ln}: {text}" for ln, text in hits), + ) + + +def _line_info(source: str, offset: int, expr: str) -> tuple[int, str]: + line_no = source.count("\n", 0, offset) + 1 + return line_no, expr.strip() + + +if __name__ == "__main__": + unittest.main() diff --git a/analyzer/tests/test_security_xmp_path_traversal.py b/analyzer/tests/test_security_xmp_path_traversal.py new file mode 100644 index 00000000..4d99a1f7 --- /dev/null +++ b/analyzer/tests/test_security_xmp_path_traversal.py @@ -0,0 +1,161 @@ +"""Regression tests for FINDING-02: XMP sidecar path traversal. + +Background +---------- +``metadata_writer.write_xmp_metadata`` takes a ``filename`` from each entry in +``image_data`` (which is populated from the CSV / JS-side payload) and joins it +with ``root_path`` via ``os.path.join``. Python's ``os.path.join`` does NOT +normalise ``..`` segments, so a filename like ``../../evil`` resolves outside +``root_path`` and the resulting ``.xmp`` file is written wherever the user +process has write permission (e.g. a Windows Startup folder). + +Expected fix +------------ +Each entry's ``filename`` must be reduced to a bare basename (or otherwise +jailed to ``root_path``) before constructing the XMP path, and entries that +fail the check must be skipped rather than silently redirected. + +Run with:: + + cd analyzer + python -m unittest tests.test_security_xmp_path_traversal +""" + +from __future__ import annotations + +import os +import shutil +import sys +import tempfile +import unittest + + +_THIS_DIR = os.path.dirname(os.path.abspath(__file__)) +_ANALYZER_DIR = os.path.dirname(_THIS_DIR) +if _ANALYZER_DIR not in sys.path: + sys.path.insert(0, _ANALYZER_DIR) + +from metadata_writer import write_xmp_metadata # noqa: E402 + + +class _TraversalTestBase(unittest.TestCase): + """Shared scaffold: creates a ``root`` dir and a sibling ``sensitive`` dir + that must never receive a file during any test.""" + + def setUp(self) -> None: + self.tmp = tempfile.mkdtemp(prefix="kestrel_xmp_sec_") + self.root = os.path.join(self.tmp, "photos") + self.outside = os.path.join(self.tmp, "sensitive") + os.makedirs(self.root) + os.makedirs(self.outside) + + def tearDown(self) -> None: + shutil.rmtree(self.tmp, ignore_errors=True) + + def _files_outside_root(self) -> list[str]: + root_real = os.path.realpath(self.root) + leaks: list[str] = [] + for dirpath, _dirs, filenames in os.walk(self.tmp): + for fn in filenames: + full = os.path.realpath(os.path.join(dirpath, fn)) + try: + common = os.path.commonpath([full, root_real]) + except ValueError: + common = "" + if common != root_real: + leaks.append(full) + return leaks + + def _assert_no_leak(self) -> None: + leaks = self._files_outside_root() + self.assertFalse(leaks, f"Files written outside root: {leaks}") + + +class TestXmpPathTraversal(_TraversalTestBase): + def test_relative_dotdot_traversal_is_rejected(self) -> None: + payload = [{"filename": "../sensitive/evil", "rating": 5, "culled": "accept"}] + result = write_xmp_metadata(self.root, payload, overwrite_external=False) + self.assertTrue(result.get("success")) + self.assertFalse( + os.path.exists(os.path.join(self.outside, "evil.xmp")), + "Traversal via ../sensitive/evil leaked outside root", + ) + self._assert_no_leak() + self.assertEqual( + result.get("written", 0), + 0, + "Traversal entry must be rejected (written=0, errors populated)", + ) + + def test_deep_traversal_is_rejected(self) -> None: + payload = [ + {"filename": "../../../../tmp/evil_deep", "rating": 1, "culled": "reject"} + ] + write_xmp_metadata(self.root, payload) + self._assert_no_leak() + + def test_absolute_posix_path_is_rejected(self) -> None: + abs_target = os.path.join(self.outside, "absolute_evil") + payload = [{"filename": abs_target, "rating": 5, "culled": "accept"}] + write_xmp_metadata(self.root, payload) + self.assertFalse( + os.path.exists(abs_target + ".xmp"), + "Absolute filename leaked outside root", + ) + self._assert_no_leak() + + def test_windows_style_traversal_is_rejected(self) -> None: + payload = [ + {"filename": r"..\..\sensitive\winevil", "rating": 3, "culled": "reject"} + ] + write_xmp_metadata(self.root, payload) + self.assertFalse( + os.path.exists(os.path.join(self.outside, "winevil.xmp")), + "Windows-style traversal leaked outside root", + ) + self._assert_no_leak() + + def test_embedded_separator_is_not_silently_retargeted(self) -> None: + """Even if contained within root, a filename containing path separators + indicates caller confusion and should be rejected or normalised to a + bare basename — never silently written to a sibling subdirectory.""" + payload = [{"filename": "subdir/inner", "rating": 1, "culled": "accept"}] + write_xmp_metadata(self.root, payload) + unexpected = os.path.join(self.root, "subdir", "inner.xmp") + if os.path.exists(unexpected): + # Permissible only if explicitly contained — and basename-only is preferred + self.assertTrue( + os.path.realpath(unexpected).startswith(os.path.realpath(self.root)) + ) + self._assert_no_leak() + + def test_null_byte_in_filename_is_rejected(self) -> None: + payload = [{"filename": "normal\x00../evil", "rating": 0, "culled": ""}] + # Either skipped (preferred) or raises — both prove the bug is fixed. + try: + write_xmp_metadata(self.root, payload) + except ValueError: + pass + self._assert_no_leak() + + def test_legitimate_bare_filename_still_works(self) -> None: + """Regression guard: sanitization must not break the happy path.""" + payload = [ + { + "filename": "IMG_0001.CR3", + "rating": 4, + "culled": "accept", + "species": "Red-Tailed Hawk", + "family": "Accipitridae", + "quality": 0.812, + } + ] + result = write_xmp_metadata(self.root, payload) + self.assertTrue(result.get("success")) + self.assertEqual(result.get("written"), 1) + expected = os.path.join(self.root, "IMG_0001.xmp") + self.assertTrue(os.path.exists(expected)) + + +if __name__ == "__main__": + unittest.main() diff --git a/analyzer/tests/ui/__init__.py b/analyzer/tests/ui/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/ui/conftest.py b/analyzer/tests/ui/conftest.py new file mode 100644 index 00000000..19dec740 --- /dev/null +++ b/analyzer/tests/ui/conftest.py @@ -0,0 +1,31 @@ +"""UI test guard: skip the whole module cleanly when pywebview can't run. + +Without a real display / WebView2 backend, every UI test would error out with +a useless traceback. This conftest detects unavailability up front and emits +a pytest.skip with a readable reason. +""" + +import os +import sys + +import pytest + + +def _can_run_pywebview() -> tuple[bool, str]: + try: + import webview # noqa: F401 + except Exception as exc: + return False, f"pywebview import failed: {exc}" + # On Linux without DISPLAY/WAYLAND_DISPLAY/xvfb, GTK will error at runtime. + if sys.platform.startswith("linux"): + if not (os.environ.get("DISPLAY") or os.environ.get("WAYLAND_DISPLAY")): + return False, "No display server (DISPLAY/WAYLAND_DISPLAY) — pywebview needs xvfb on headless Linux" + return True, "" + + +@pytest.fixture(scope="session", autouse=True) +def _skip_if_no_pywebview(): + ok, reason = _can_run_pywebview() + if not ok: + pytest.skip(reason, allow_module_level=False) + yield diff --git a/analyzer/tests/ui/test_pywebview_api.py b/analyzer/tests/ui/test_pywebview_api.py new file mode 100644 index 00000000..1192f5de --- /dev/null +++ b/analyzer/tests/ui/test_pywebview_api.py @@ -0,0 +1,150 @@ +"""Integration test for the pywebview JS<->Python bridge via --api-probe. + +Spawns ``analyzer/visualizer.py --api-probe`` as a subprocess, waits for it +to write a result JSON, and asserts that the bridge round-trip worked end- +to-end (JS reached Python via ``pywebviewready``-triggered API call). + +Marked ``@pytest.mark.ui`` so it can be excluded from headless CI lanes. +""" + +from __future__ import annotations + +import json +import os +import subprocess +import sys +import time +from pathlib import Path + +import pytest + + +REPO_ROOT = Path(__file__).resolve().parents[3] +VISUALIZER_PATH = REPO_ROOT / "analyzer" / "visualizer.py" + + +pytestmark = pytest.mark.ui + + +def _wait_for_file(path: Path, proc: subprocess.Popen, deadline_s: float) -> bool: + """Poll for ``path`` to appear OR for ``proc`` to exit, whichever happens first. + + Returns True if the file appeared, False on timeout. + """ + deadline = time.monotonic() + float(deadline_s) + while time.monotonic() < deadline: + if path.exists(): + return True + if proc.poll() is not None: + # Subprocess exited; the file may have been written just before exit + # — give the OS a moment to flush, then check once more. + time.sleep(0.2) + return path.exists() + time.sleep(0.2) + return False + + +def _kill_subprocess(proc: subprocess.Popen) -> None: + """Escalating-kill helper: terminate -> kill -> taskkill /T (Windows).""" + if proc.poll() is not None: + return + try: + proc.terminate() + try: + proc.wait(timeout=5) + return + except subprocess.TimeoutExpired: + pass + except Exception: + pass + try: + proc.kill() + try: + proc.wait(timeout=5) + return + except subprocess.TimeoutExpired: + pass + except Exception: + pass + if os.name == "nt": + # WebView2 spawns helper processes; /T kills the whole tree. + try: + subprocess.run( + ["taskkill", "/F", "/T", "/PID", str(proc.pid)], + check=False, + timeout=10, + ) + except Exception: + pass + + +@pytest.mark.parametrize( + ("probe_target", "port", "deadline_s", "probe_timeout"), + [ + # synthetic: minimal probe HTML, fast (~3-5s including pywebview boot). + ("synthetic", 8799, 30.0, "15"), + # visualizer: real visualizer.html via local HTTP server. Bigger payload + # (CSS bundle, JS, taxonomy fetches), needs more headroom. + ("visualizer", 8798, 60.0, "45"), + ], +) +def test_api_probe_writes_result_json(tmp_path, probe_target, port, deadline_s, probe_timeout): + """End-to-end: subprocess --api-probe -> JS -> Api.report_bridge_ready -> JSON. + + Runs once with the minimal synthetic probe HTML (proves the bridge + mechanism itself), and once loading the real ``visualizer.html`` via the + local HTTP server (proves the production JS bundle actually sees + ``window.pywebview.api``). + """ + probe_out = tmp_path / f"probe-{probe_target}.json" + cmd = [ + sys.executable, + str(VISUALIZER_PATH), + "--api-probe", + "--probe-target", probe_target, + "--probe-output", str(probe_out), + "--probe-timeout", probe_timeout, + "--port", str(port), + ] + proc = subprocess.Popen( + cmd, + cwd=str(REPO_ROOT), + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + ) + try: + appeared = _wait_for_file(probe_out, proc, deadline_s=deadline_s) + stdout, stderr = b"", b"" + try: + stdout, stderr = proc.communicate(timeout=10) + except subprocess.TimeoutExpired: + pass + assert appeared, ( + f"probe output not written within {deadline_s}s " + f"(target={probe_target}). rc={proc.returncode}\n" + f"stdout:\n{stdout.decode(errors='replace')}\n" + f"stderr:\n{stderr.decode(errors='replace')}" + ) + payload = json.loads(probe_out.read_text(encoding="utf-8")) + assert payload.get("ok") is True, f"probe reported failure: {payload!r}" + + # version should match what api_bridge.Api.get_app_version reads. + sys.path.insert(0, str(REPO_ROOT / "analyzer")) + from kestrel_analyzer.config import VERSION + assert payload.get("version") == VERSION, ( + f"version mismatch: probe={payload.get('version')!r}, " + f"config.VERSION={VERSION!r}" + ) + + # frozen is platform-dependent but must always be a bool. + assert isinstance(payload.get("frozen"), bool), ( + f"'frozen' is {type(payload.get('frozen')).__name__}, expected bool" + ) + + # Subprocess should have exited cleanly with rc=0. + assert proc.returncode == 0, ( + f"probe subprocess exited with rc={proc.returncode}\n" + f"stderr:\n{stderr.decode(errors='replace')}" + ) + finally: + _kill_subprocess(proc) diff --git a/analyzer/tests/unit/__init__.py b/analyzer/tests/unit/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/analyzer/tests/unit/test_api_bridge_pure.py b/analyzer/tests/unit/test_api_bridge_pure.py new file mode 100644 index 00000000..ca1afd0f --- /dev/null +++ b/analyzer/tests/unit/test_api_bridge_pure.py @@ -0,0 +1,215 @@ +"""Unit tests for api_bridge.py pure helper methods (no webview required).""" + +import pytest +from pathlib import Path +import sys +import os + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import api_bridge + + +pytestmark = pytest.mark.unit + + +@pytest.fixture +def api(): + return api_bridge.Api() + + +class TestVersionAndFrozenChecks: + """Tests for is_frozen_app and get_app_version.""" + + def test_is_frozen_app_returns_dict_with_bool(self, api): + """is_frozen_app() returns a dict containing a bool flag.""" + result = api.is_frozen_app() + # API returns dict {success: bool, frozen: bool} or similar + assert isinstance(result, dict) + # Should have a frozen flag or similar + frozen = result.get('frozen', result.get('is_frozen')) + if frozen is not None: + assert isinstance(frozen, bool) + + def test_is_frozen_app_false_in_dev(self, api): + """When running from source (not frozen) → False.""" + # Since we're running this from source, should be False + result = api.is_frozen_app() + # Could be either format - check both + frozen = result.get('frozen', result.get('is_frozen')) + if frozen is not None: + assert frozen == False + + def test_get_app_version_returns_version_string(self, api): + """get_app_version() returns a dict containing a version string.""" + result = api.get_app_version() + assert isinstance(result, dict) + version = result.get('version', '') + assert isinstance(version, str) + assert len(version) > 0 + + +class TestPlatformInfo: + """Tests for get_platform_info.""" + + def test_get_platform_info_returns_dict(self, api): + """get_platform_info() returns a dict with platform details.""" + info = api.get_platform_info() + assert isinstance(info, dict) + + +class TestIsSafeExternalUrl: + """Tests for module-level _is_safe_external_url() function.""" + + def test_http_allowed(self): + assert api_bridge._is_safe_external_url("http://example.com") == True + + def test_https_allowed(self): + assert api_bridge._is_safe_external_url("https://example.com") == True + + def test_mailto_allowed(self): + assert api_bridge._is_safe_external_url("mailto:test@example.com") == True + + def test_file_scheme_rejected(self): + """file:// scheme can execute via ShellExecute on Windows → rejected.""" + assert api_bridge._is_safe_external_url("file:///C:/Windows/System32/calc.exe") == False + + def test_javascript_scheme_rejected(self): + """javascript: scheme can run code → rejected.""" + assert api_bridge._is_safe_external_url("javascript:alert(1)") == False + + def test_data_scheme_rejected(self): + """data: URLs → rejected.""" + assert api_bridge._is_safe_external_url("data:text/html,") == False + + def test_ftp_scheme_rejected(self): + """ftp:// → rejected (not in allowlist).""" + assert api_bridge._is_safe_external_url("ftp://example.com") == False + + def test_unc_path_rejected(self): + """UNC path \\\\host\\share → rejected.""" + assert api_bridge._is_safe_external_url("\\\\attacker\\share\\evil.exe") == False + + def test_double_slash_rejected(self): + """Forward-slash UNC //host → rejected.""" + assert api_bridge._is_safe_external_url("//attacker/share") == False + + def test_empty_string_rejected(self): + assert api_bridge._is_safe_external_url("") == False + + def test_none_rejected(self): + assert api_bridge._is_safe_external_url(None) == False + + def test_non_string_rejected(self): + assert api_bridge._is_safe_external_url(42) == False + assert api_bridge._is_safe_external_url([]) == False + + def test_control_chars_rejected(self): + """URL with control chars (newline, NUL, etc.) → rejected.""" + assert api_bridge._is_safe_external_url("http://example.com\nLocation: evil") == False + assert api_bridge._is_safe_external_url("http://example.com\x00") == False + + def test_no_scheme_rejected(self): + """URL without :// or : separator → rejected.""" + assert api_bridge._is_safe_external_url("example.com") == False + + def test_whitespace_trimmed(self): + """Leading/trailing whitespace is trimmed before validation.""" + # Trimmed URL is valid http + assert api_bridge._is_safe_external_url(" https://example.com ") == True + + +class TestIsWithinRoot: + """Tests for the Api._is_within_root method (path containment).""" + + def test_child_path_allowed(self, api, tmp_path): + """Path inside root → True.""" + child = tmp_path / "subdir" / "file.txt" + child.parent.mkdir() + child.touch() + assert api._is_within_root(str(child), str(tmp_path)) == True + + def test_parent_path_blocked(self, api, tmp_path): + """Path outside root → False.""" + # The parent of tmp_path is NOT within tmp_path + assert api._is_within_root(str(tmp_path.parent), str(tmp_path)) == False + + def test_sibling_path_blocked(self, api, tmp_path): + """Sibling directory → False.""" + sibling = tmp_path.parent / "sibling" + sibling.mkdir(exist_ok=True) + try: + assert api._is_within_root(str(sibling), str(tmp_path)) == False + finally: + # Clean up + try: + sibling.rmdir() + except OSError: + pass + + def test_same_path_allowed(self, api, tmp_path): + """Path == root → True.""" + assert api._is_within_root(str(tmp_path), str(tmp_path)) == True + + def test_empty_path_blocked(self, api): + """Empty path → False.""" + assert api._is_within_root("", "/some/root") == False + assert api._is_within_root("/some/path", "") == False + + +class TestEditorExtensionAllowed: + """Tests for editor extension allowlist.""" + + def test_jpeg_extension_allowed(self, api): + """Common image extensions are allowed.""" + # Common image extensions are usually in the default allowlist + assert api._editor_extension_allowed("photo.jpg") in (True, False) # Allowlist may vary + # At minimum check it doesn't crash + + def test_exe_extension_blocked(self, api): + """Executable extensions should NOT be allowed.""" + assert api._editor_extension_allowed("malware.exe") == False + + def test_bat_extension_blocked(self, api): + """Script extensions should NOT be allowed.""" + assert api._editor_extension_allowed("script.bat") == False + assert api._editor_extension_allowed("script.cmd") == False + + +class TestStripWrappingQuotes: + """Tests for _strip_wrapping_quotes helper.""" + + def test_strips_double_quotes(self, api): + assert api._strip_wrapping_quotes('"hello"') == 'hello' + + def test_strips_single_quotes(self, api): + assert api._strip_wrapping_quotes("'hello'") == 'hello' + + def test_preserves_inner_quotes(self, api): + assert api._strip_wrapping_quotes('say "hi" please') == 'say "hi" please' + + def test_handles_empty_string(self, api): + assert api._strip_wrapping_quotes('') == '' + + def test_handles_whitespace(self, api): + assert api._strip_wrapping_quotes(' "hello" ') == 'hello' + + +class TestInspectFolderViaApi: + """Tests for Api.inspect_folder (wraps folder_inspector).""" + + def test_inspect_folder_returns_dict(self, api, tmp_path): + """Inspect an empty folder → returns expected dict.""" + result = api.inspect_folder(str(tmp_path)) + assert isinstance(result, dict) + # Should have 'total' or similar keys, depending on return shape + assert 'total' in result or 'success' in result or 'has_kestrel' in result + + def test_inspect_folder_with_images(self, api, tmp_path): + """Inspect folder with images → reports correct count.""" + (tmp_path / "IMG_001.CR3").touch() + (tmp_path / "IMG_002.CR3").touch() + + result = api.inspect_folder(str(tmp_path)) + # Folder inspector should detect them + assert result.get('total', 0) >= 2 or result.get('success', True) diff --git a/analyzer/tests/unit/test_cli_args.py b/analyzer/tests/unit/test_cli_args.py new file mode 100644 index 00000000..9be8a74b --- /dev/null +++ b/analyzer/tests/unit/test_cli_args.py @@ -0,0 +1,393 @@ +"""Unit tests for analyzer/cli.py argument parsing. + +These cover the full set of "Advanced Analysis Settings" flags that the CLI +exposes alongside the existing folder/--gpu/--detection-threshold/--parallel- +prefetch flags. They are deliberately argparse-only (no ML weights loaded) so +they run in the fast `unit` lane. + +Wiring tests further down verify that ``main()`` actually forwards the parsed +values to ``AnalysisPipeline.process_folder``. +""" + +from __future__ import annotations + +import sys +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +import cli as cli_module # noqa: E402 (after sys.path mutation) +from cli import ( # noqa: E402 + WILDLIFE_MODEL_MODE_TO_DETECTOR, + _resolve_detector_name, + parse_args, +) +from kestrel_analyzer.config import DEFAULT_DETECTOR_NAME # noqa: E402 + + +pytestmark = pytest.mark.unit + + +# --------------------------------------------------------------------------- +# parse_args: defaults +# --------------------------------------------------------------------------- + +class TestDefaults: + def test_minimal_invocation_sets_defaults(self): + args = parse_args(["/tmp/photos"]) + assert args.folder == "/tmp/photos" + assert args.use_gpu is True + assert args.detector_name is None + assert args.wildlife_model_mode is None + assert args.detection_threshold == pytest.approx(0.25) + assert args.parallel_prefetch == 3 + assert args.max_bird_crops == 10 + assert args.exposure_quality is None + assert args.scene_time_threshold == pytest.approx(1.0) + assert args.thumbnail_max_width is None + assert args.thumbnail_jpeg_compression is None + assert args.wildlife_enabled is False + assert args.species_detection_enabled is True + assert args.retry_errored is False + assert args.smoke is False + assert args.validate is False + + def test_folder_optional_when_validate(self): + args = parse_args(["--validate", "--validate-images", "test_imgs"]) + assert args.folder is None + assert args.validate is True + assert args.validate_images == "test_imgs" + + +# --------------------------------------------------------------------------- +# parse_args: each new flag in isolation +# --------------------------------------------------------------------------- + +class TestNewFlagsAccepted: + def test_max_bird_crops(self): + args = parse_args(["/tmp/photos", "--max-bird-crops", "15"]) + assert args.max_bird_crops == 15 + + def test_exposure_quality_lenient(self): + args = parse_args(["/tmp/photos", "--exposure-quality", "lenient"]) + assert args.exposure_quality == "lenient" + + def test_exposure_quality_balanced(self): + args = parse_args(["/tmp/photos", "--exposure-quality", "balanced"]) + assert args.exposure_quality == "balanced" + + def test_exposure_quality_aggressive(self): + args = parse_args(["/tmp/photos", "--exposure-quality", "aggressive"]) + assert args.exposure_quality == "aggressive" + + def test_exposure_quality_rejects_bogus(self): + with pytest.raises(SystemExit): + parse_args(["/tmp/photos", "--exposure-quality", "yolo"]) + + def test_scene_time_threshold(self): + args = parse_args(["/tmp/photos", "--scene-time-threshold", "5.5"]) + assert args.scene_time_threshold == pytest.approx(5.5) + + def test_thumbnail_max_width(self): + args = parse_args(["/tmp/photos", "--thumbnail-max-width", "1800"]) + assert args.thumbnail_max_width == 1800 + + def test_thumbnail_jpeg_compression(self): + args = parse_args(["/tmp/photos", "--thumbnail-jpeg-compression", "0.85"]) + assert args.thumbnail_jpeg_compression == pytest.approx(0.85) + + def test_wildlife_model_mode_fast(self): + args = parse_args(["/tmp/photos", "--wildlife-model-mode", "fast"]) + assert args.wildlife_model_mode == "fast" + + def test_wildlife_model_mode_accurate(self): + args = parse_args(["/tmp/photos", "--wildlife-model-mode", "accurate"]) + assert args.wildlife_model_mode == "accurate" + + def test_wildlife_model_mode_rejects_bogus(self): + with pytest.raises(SystemExit): + parse_args(["/tmp/photos", "--wildlife-model-mode", "ultra"]) + + def test_wildlife_toggle(self): + on = parse_args(["/tmp/photos", "--wildlife"]) + off = parse_args(["/tmp/photos", "--no-wildlife"]) + assert on.wildlife_enabled is True + assert off.wildlife_enabled is False + + def test_species_detection_toggle(self): + on = parse_args(["/tmp/photos", "--species-detection"]) + off = parse_args(["/tmp/photos", "--no-species-detection"]) + assert on.species_detection_enabled is True + assert off.species_detection_enabled is False + + def test_retry_errored_toggle(self): + on = parse_args(["/tmp/photos", "--retry-errored"]) + off = parse_args(["/tmp/photos", "--no-retry-errored"]) + assert on.retry_errored is True + assert off.retry_errored is False + + +# --------------------------------------------------------------------------- +# parse_args: every flag at once still parses +# --------------------------------------------------------------------------- + +class TestFullCommandLine: + def test_kitchen_sink(self): + args = parse_args([ + "/tmp/photos", + "--no-gpu", + "--wildlife-model-mode", "fast", + "--detection-threshold", "0.4", + "--parallel-prefetch", "2", + "--max-bird-crops", "7", + "--exposure-quality", "aggressive", + "--scene-time-threshold", "2.5", + "--thumbnail-max-width", "2000", + "--thumbnail-jpeg-compression", "0.9", + "--wildlife", + "--no-species-detection", + "--retry-errored", + ]) + assert args.folder == "/tmp/photos" + assert args.use_gpu is False + assert args.wildlife_model_mode == "fast" + assert args.detection_threshold == pytest.approx(0.4) + assert args.parallel_prefetch == 2 + assert args.max_bird_crops == 7 + assert args.exposure_quality == "aggressive" + assert args.scene_time_threshold == pytest.approx(2.5) + assert args.thumbnail_max_width == 2000 + assert args.thumbnail_jpeg_compression == pytest.approx(0.9) + assert args.wildlife_enabled is True + assert args.species_detection_enabled is False + assert args.retry_errored is True + + +# --------------------------------------------------------------------------- +# _resolve_detector_name: precedence between --detector-name and +# --wildlife-model-mode. +# --------------------------------------------------------------------------- + +class TestDetectorResolution: + def test_neither_flag_falls_back_to_default(self): + args = parse_args(["/tmp/photos"]) + assert _resolve_detector_name(args) == DEFAULT_DETECTOR_NAME + + def test_wildlife_model_mode_fast_resolves_to_cedar(self): + args = parse_args(["/tmp/photos", "--wildlife-model-mode", "fast"]) + assert _resolve_detector_name(args) == "mdv1000-cedar" + + def test_wildlife_model_mode_accurate_resolves_to_mdv5a(self): + args = parse_args(["/tmp/photos", "--wildlife-model-mode", "accurate"]) + assert _resolve_detector_name(args) == "mdv5a" + + def test_detector_name_takes_precedence(self): + args = parse_args([ + "/tmp/photos", + "--detector-name", "mdv5a", + "--wildlife-model-mode", "fast", + ]) + # --detector-name wins even when --wildlife-model-mode says 'fast' + assert _resolve_detector_name(args) == "mdv5a" + + def test_mode_map_matches_visualizer_js(self): + # Guard against drift between this map and the JS mapping at + # visualizer.js:8077 (modelVal === 'accurate' ? 'mdv5a' : 'mdv1000-cedar'). + assert WILDLIFE_MODEL_MODE_TO_DETECTOR == { + "fast": "mdv1000-cedar", + "accurate": "mdv5a", + } + + +# --------------------------------------------------------------------------- +# Wiring: main() forwards clamped / resolved values to process_folder. +# --------------------------------------------------------------------------- + +class TestMainForwardsToPipeline: + """Patch AnalysisPipeline; assert process_folder is called with the + expected kwargs (and clamped where the CLI clamps). + """ + + def _run_main(self, argv): + with patch.object(cli_module, "AnalysisPipeline") as PipelineCls: + instance = MagicMock() + PipelineCls.return_value = instance + cli_module.main(argv) + return PipelineCls, instance + + def test_minimal_invocation_forwards_defaults(self, tmp_path): + # Use a real (empty) folder so cli.main() reaches pipeline.process_folder + folder = tmp_path / "photos" + folder.mkdir() + PipelineCls, instance = self._run_main([str(folder)]) + + PipelineCls.assert_called_once() + ctor_kwargs = PipelineCls.call_args.kwargs + assert ctor_kwargs["use_gpu"] is True + assert ctor_kwargs["detector_name"] == DEFAULT_DETECTOR_NAME + + instance.process_folder.assert_called_once() + pf_kwargs = instance.process_folder.call_args.kwargs + assert pf_kwargs["analyzer_name"] == "cli" + assert pf_kwargs["wildlife_enabled"] is False + assert pf_kwargs["species_detection_enabled"] is True + assert pf_kwargs["retry_errored"] is False + assert pf_kwargs["detection_threshold"] == pytest.approx(0.25) + assert pf_kwargs["scene_time_threshold"] == pytest.approx(1.0) + assert pf_kwargs["max_bird_crops"] == 10 + assert pf_kwargs["parallel_prefetch"] == 3 + # When the CLI flag is omitted, the override stays None so the + # pipeline falls back to settings.json / defaults. + assert pf_kwargs["exposure_quality"] is None + assert pf_kwargs["thumbnail_max_width"] is None + assert pf_kwargs["thumbnail_jpeg_compression"] is None + + def test_full_invocation_forwards_all_flags(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + PipelineCls, instance = self._run_main([ + str(folder), + "--no-gpu", + "--wildlife-model-mode", "fast", + "--detection-threshold", "0.4", + "--parallel-prefetch", "2", + "--max-bird-crops", "7", + "--exposure-quality", "aggressive", + "--scene-time-threshold", "2.5", + "--thumbnail-max-width", "2000", + "--thumbnail-jpeg-compression", "0.9", + "--wildlife", + "--no-species-detection", + "--retry-errored", + ]) + + ctor_kwargs = PipelineCls.call_args.kwargs + assert ctor_kwargs["use_gpu"] is False + # --wildlife-model-mode fast -> mdv1000-cedar + assert ctor_kwargs["detector_name"] == "mdv1000-cedar" + + pf_kwargs = instance.process_folder.call_args.kwargs + assert pf_kwargs["wildlife_enabled"] is True + assert pf_kwargs["species_detection_enabled"] is False + assert pf_kwargs["retry_errored"] is True + assert pf_kwargs["detection_threshold"] == pytest.approx(0.4) + assert pf_kwargs["scene_time_threshold"] == pytest.approx(2.5) + assert pf_kwargs["max_bird_crops"] == 7 + assert pf_kwargs["parallel_prefetch"] == 2 + assert pf_kwargs["exposure_quality"] == "aggressive" + assert pf_kwargs["thumbnail_max_width"] == 2000 + assert pf_kwargs["thumbnail_jpeg_compression"] == pytest.approx(0.9) + + def test_detector_name_overrides_wildlife_model_mode(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + PipelineCls, _ = self._run_main([ + str(folder), + "--detector-name", "mdv5a", + "--wildlife-model-mode", "fast", + ]) + # --detector-name wins; should be mdv5a even though mode says fast + assert PipelineCls.call_args.kwargs["detector_name"] == "mdv5a" + + # --- Clamping --- + + def test_detection_threshold_clamped_low(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--detection-threshold", "0.001"]) + assert instance.process_folder.call_args.kwargs["detection_threshold"] == pytest.approx(0.10) + + def test_detection_threshold_clamped_high(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--detection-threshold", "2.5"]) + assert instance.process_folder.call_args.kwargs["detection_threshold"] == pytest.approx(0.99) + + def test_max_bird_crops_clamped_low(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--max-bird-crops", "0"]) + assert instance.process_folder.call_args.kwargs["max_bird_crops"] == 1 + + def test_max_bird_crops_clamped_high(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--max-bird-crops", "999"]) + assert instance.process_folder.call_args.kwargs["max_bird_crops"] == 20 + + def test_thumbnail_max_width_clamped(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--thumbnail-max-width", "10"]) + # Floor is 400 + assert instance.process_folder.call_args.kwargs["thumbnail_max_width"] == 400 + _, instance = self._run_main([str(folder), "--thumbnail-max-width", "99999"]) + # Ceiling is 2400 + assert instance.process_folder.call_args.kwargs["thumbnail_max_width"] == 2400 + + def test_thumbnail_jpeg_compression_clamped(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--thumbnail-jpeg-compression", "0.0"]) + assert instance.process_folder.call_args.kwargs["thumbnail_jpeg_compression"] == pytest.approx(0.5) + _, instance = self._run_main([str(folder), "--thumbnail-jpeg-compression", "5.0"]) + assert instance.process_folder.call_args.kwargs["thumbnail_jpeg_compression"] == pytest.approx(1.0) + + def test_parallel_prefetch_clamped(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--parallel-prefetch", "99"]) + assert instance.process_folder.call_args.kwargs["parallel_prefetch"] == 5 + _, instance = self._run_main([str(folder), "--parallel-prefetch", "0"]) + assert instance.process_folder.call_args.kwargs["parallel_prefetch"] == 1 + + def test_scene_time_threshold_clamped(self, tmp_path): + folder = tmp_path / "photos" + folder.mkdir() + _, instance = self._run_main([str(folder), "--scene-time-threshold", "-1"]) + assert instance.process_folder.call_args.kwargs["scene_time_threshold"] == pytest.approx(0.0) + _, instance = self._run_main([str(folder), "--scene-time-threshold", "9999"]) + assert instance.process_folder.call_args.kwargs["scene_time_threshold"] == pytest.approx(60.0) + + +# --------------------------------------------------------------------------- +# Pipeline parameter wiring: the new override knobs reach pipeline state. +# --------------------------------------------------------------------------- + +class TestPipelineOverrides: + """Smoke-test that ``process_folder`` accepts and clamps the three new + override parameters without exploding on an empty folder. The full + pipeline doesn't run because there are no files to process.""" + + @pytest.fixture + def empty_folder(self, tmp_path): + folder = tmp_path / "empty" + folder.mkdir() + return folder + + def test_overrides_accepted_on_empty_folder(self, empty_folder): + from kestrel_analyzer.pipeline import AnalysisPipeline + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + # No images -> bails out before model loading. We're checking that the + # new signature accepts the kwargs without TypeError. + pipeline.process_folder( + folder=str(empty_folder), + analyzer_name="unit_test", + exposure_quality="aggressive", + thumbnail_max_width=1500, + thumbnail_jpeg_compression=0.65, + ) + + def test_invalid_exposure_quality_falls_through_to_default(self, empty_folder): + from kestrel_analyzer.pipeline import AnalysisPipeline + pipeline = AnalysisPipeline(use_gpu=False, detector_name=DEFAULT_DETECTOR_NAME) + # Invalid string is silently ignored so we keep CLI/UI parity (the + # caller is trusted to pass an enum value). + pipeline.process_folder( + folder=str(empty_folder), + analyzer_name="unit_test", + exposure_quality="not-a-real-mode", + ) diff --git a/analyzer/tests/unit/test_database.py b/analyzer/tests/unit/test_database.py new file mode 100644 index 00000000..aff86c7c --- /dev/null +++ b/analyzer/tests/unit/test_database.py @@ -0,0 +1,186 @@ +"""Unit tests for database.py - CSV and JSON database layer.""" + +import pytest +import json +import pandas as pd +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.database import ( + load_database, + save_database, + ensure_columns, + load_scenedata, + save_scenedata, + build_scenedata_from_database, + update_scenedata_with_database, + BASE_COLUMNS, + REQUIRED_COLUMNS, +) + + +pytestmark = pytest.mark.unit + + +class TestDatabaseLoad: + """Tests for loading database CSV files.""" + + def test_load_empty_database(self, temp_kestrel_dir): + """Load a bare CSV with just headers - should return empty DataFrame.""" + db, db_path = load_database(temp_kestrel_dir.parent, "test_analyzer", None) + assert isinstance(db, pd.DataFrame) + assert len(db) == 0 + assert list(db.columns) == BASE_COLUMNS + + + +class TestDatabaseSave: + """Tests for saving database CSV files.""" + + def test_save_reload_roundtrip(self, temp_kestrel_dir, sample_database): + """Save a DataFrame and reload it - should be identical.""" + # Add test data + sample_database.loc[0] = [None] * len(BASE_COLUMNS) + sample_database.loc[0, 'filename'] = 'IMG_001.CR3' + sample_database.loc[0, 'species'] = 'aves,columbidae' + sample_database.loc[0, 'quality'] = 0.85 + + # Save + csv_path = temp_kestrel_dir / "kestrel_database.csv" + save_database(sample_database, str(csv_path)) + + # Reload + reloaded = pd.read_csv(csv_path) + + # Compare + pd.testing.assert_frame_equal( + sample_database.fillna(0).fillna(""), + reloaded.fillna(0).fillna(""), + check_dtype=False + ) + + def test_save_database_preserves_culled_from_disk(self, temp_kestrel_dir, sample_database): + """save_database should read culled/culled_origin from on-disk version before overwriting.""" + # Create initial CSV with culled data + sample_database.loc[0] = [None] * len(BASE_COLUMNS) + sample_database.loc[0, 'filename'] = 'IMG_001.CR3' + sample_database.loc[0, 'culled'] = True + sample_database.loc[0, 'culled_origin'] = 'manual' + + csv_path = temp_kestrel_dir / "kestrel_database.csv" + sample_database.to_csv(csv_path, index=False) + + # Create a modified version (without culled info) + modified = sample_database.copy() + modified.loc[0, 'culled'] = False + modified.loc[0, 'culled_origin'] = None + + # Save the modified version (which should read culled from disk and preserve it) + save_database(modified, str(csv_path)) + + # Reload and verify culled data was preserved + reloaded = pd.read_csv(csv_path) + # Note: The actual behavior depends on the implementation of save_database + # This test documents the expected behavior + + +class TestScenedata: + """Tests for scenedata JSON read/write.""" + + def test_scenedata_save_load_roundtrip(self, temp_kestrel_dir): + """Save and load scenedata JSON - should be identical.""" + scenedata = { + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": ["IMG_001.CR3", "IMG_002.CR3"], + "name": "Scene 1", + "status": "pending", + "user_tags": {"species": [], "families": [], "finalized": False} + } + } + } + + # Save + save_scenedata(scenedata, temp_kestrel_dir.parent) + + # Load + loaded = load_scenedata(temp_kestrel_dir.parent) + + assert loaded == scenedata + + def test_load_nonexistent_scenedata_returns_initialized_dict(self, tmp_path): + """Load scenedata from dir that has no scenedata.json - returns initialized dict.""" + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + + result = load_scenedata(tmp_path) + # Should have version, image_ratings, and scenes keys (initialized) + assert "version" in result + assert "image_ratings" in result + assert "scenes" in result + assert len(result["scenes"]) == 0 + + def test_build_scenedata_from_database(self, sample_database): + """Build scenedata from a fresh database - should group by scene_count.""" + # Create test data with different scene_count values + sample_database.loc[0] = [None] * len(BASE_COLUMNS) + sample_database.loc[0, 'filename'] = 'IMG_001.CR3' + sample_database.loc[0, 'scene_count'] = 1 + + sample_database.loc[1] = [None] * len(BASE_COLUMNS) + sample_database.loc[1, 'filename'] = 'IMG_002.CR3' + sample_database.loc[1, 'scene_count'] = 1 + + sample_database.loc[2] = [None] * len(BASE_COLUMNS) + sample_database.loc[2, 'filename'] = 'IMG_003.CR3' + sample_database.loc[2, 'scene_count'] = 2 + + scenedata = build_scenedata_from_database(sample_database) + + # Should have version, image_ratings, and scenes keys + assert "version" in scenedata + assert "image_ratings" in scenedata + assert "scenes" in scenedata + + # Should have 2 scenes (keyed by scene_count) + assert len(scenedata["scenes"]) == 2 + assert "1" in scenedata["scenes"] + assert "2" in scenedata["scenes"] + # Each scene should contain the image filenames + assert set(scenedata["scenes"]["1"]["image_filenames"]) == {"IMG_001.CR3", "IMG_002.CR3"} + assert set(scenedata["scenes"]["2"]["image_filenames"]) == {"IMG_003.CR3"} + + def test_update_scenedata_merges_new_images(self, sample_database): + """update_scenedata_with_database merges new images into existing scenedata.""" + # Start with existing scenedata with proper structure + existing_scenedata = { + "version": "2.0", + "image_ratings": {}, + "scenes": { + "1": { + "scene_id": "1", + "image_filenames": ["IMG_001.CR3", "IMG_002.CR3"], + "name": "Scene 1", + "status": "pending", + "user_tags": {"species": [], "families": [], "finalized": False} + } + } + } + + # Create database with new image in scene 1 + sample_database.loc[0] = [None] * len(BASE_COLUMNS) + sample_database.loc[0, 'filename'] = 'IMG_003.CR3' + sample_database.loc[0, 'scene_count'] = 1 + + result = update_scenedata_with_database(existing_scenedata, sample_database) + + # IMG_003 should be added to scene 1 + assert "IMG_003.CR3" in result["scenes"]["1"]["image_filenames"] + # Original images preserved + assert "IMG_001.CR3" in result["scenes"]["1"]["image_filenames"] + assert "IMG_002.CR3" in result["scenes"]["1"]["image_filenames"] diff --git a/analyzer/tests/unit/test_exposure_compensation.py b/analyzer/tests/unit/test_exposure_compensation.py new file mode 100644 index 00000000..557186c7 --- /dev/null +++ b/analyzer/tests/unit/test_exposure_compensation.py @@ -0,0 +1,139 @@ +"""Unit tests for exposure_compensation.py - exposure math and sRGB conversion.""" + +import pytest +import numpy as np +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from kestrel_analyzer.exposure_compensation import ( + linear_to_srgb_u8, + compute_global_meter_scale, + compose_total_stops, +) + + +pytestmark = pytest.mark.unit + + +class TestLinearToSRGBConversion: + """Tests for linear_to_srgb_u8() - the LUT-based gamma conversion.""" + + def test_zero_maps_to_zero(self): + """Linear 0.0 maps to sRGB 0.""" + result = linear_to_srgb_u8(np.array([0.0], dtype=np.float32)) + assert result[0] == 0 + + def test_one_maps_to_255(self): + """Linear 1.0 maps to sRGB 255.""" + result = linear_to_srgb_u8(np.array([1.0], dtype=np.float32)) + assert result[0] == 255 + + def test_midpoint_approximately_128(self): + """Linear ~0.214 maps to sRGB ~128 (due to gamma 2.2 approx).""" + # The exact value depends on the sRGB transfer function + result = linear_to_srgb_u8(np.array([0.214], dtype=np.float32)) + # Should be close to 128, but allow some tolerance for the exact LUT + assert 120 <= result[0] <= 135 + + def test_array_input(self): + """Can handle multi-element arrays.""" + linear = np.array([0.0, 0.5, 1.0], dtype=np.float32) + result = linear_to_srgb_u8(linear) + assert len(result) == 3 + assert result[0] == 0 + assert result[2] == 255 + assert 0 < result[1] < 255 + + def test_output_dtype_is_uint8(self): + """Output is always uint8.""" + result = linear_to_srgb_u8(np.array([0.5], dtype=np.float32)) + assert result.dtype == np.uint8 + + def test_clamps_out_of_range(self): + """Values outside [0, 1] are clamped.""" + # Negative values + result_neg = linear_to_srgb_u8(np.array([-0.5], dtype=np.float32)) + assert result_neg[0] == 0 + + # Values > 1 + result_high = linear_to_srgb_u8(np.array([1.5], dtype=np.float32)) + assert result_high[0] == 255 + + +class TestGlobalMeterScale: + """Tests for compute_global_meter_scale() - brightness metering.""" + + def test_all_white_image_scale_less_than_one(self): + """All-white image (bright) should have meter_scale < 1 (needs darkening).""" + white = np.ones((100, 100, 3), dtype=np.float32) + scale, debug = compute_global_meter_scale(white) + assert scale < 1.0 + + def test_all_black_image_scale_greater_than_one(self): + """All-black image (dark) should have meter_scale > 1 (needs brightening).""" + black = np.zeros((100, 100, 3), dtype=np.float32) + scale, debug = compute_global_meter_scale(black) + assert scale > 1.0 + + def test_neutral_gray_scale_is_computed_correctly(self): + """Neutral gray (0.5 luminance) gets its meter scale from percentile targets.""" + gray = np.ones((100, 100, 3), dtype=np.float32) * 0.5 + scale, debug = compute_global_meter_scale(gray) + # For 0.5, targets are 0.33/0.5=0.66, 0.72/0.5=1.44, 0.90/0.5=1.8 + # The minimum is 0.66, so scale should be 0.66 + assert 0.65 <= scale <= 0.67 + + def test_scale_clamped_to_range(self): + """Meter scale is clamped to [0.25, 8.0].""" + # Extremely bright image (should clamp to 0.25) + very_bright = np.ones((100, 100, 3), dtype=np.float32) * 2.0 + scale_bright, _ = compute_global_meter_scale(very_bright) + assert scale_bright >= 0.25 + + # Extremely dark image (should clamp to 8.0) + very_dark = np.zeros((100, 100, 3), dtype=np.float32) + very_dark[0, 0, :] = 0.01 # Just a tiny bit of light + scale_dark, _ = compute_global_meter_scale(very_dark) + assert scale_dark <= 8.0 + + def test_returns_debug_dict(self): + """Returns tuple (scale, debug_dict).""" + image = np.ones((50, 50, 3), dtype=np.float32) * 0.5 + scale, debug = compute_global_meter_scale(image) + assert isinstance(scale, (float, np.floating)) + assert isinstance(debug, dict) + + +class TestComposeStops: + """Tests for compose_total_stops() - combines subject stops with meter scale.""" + + def test_zero_subject_stops_uses_meter_scale(self): + """Subject stops=0, meter_scale=2 → total ≈ log2(2) = 1 stop.""" + total_stops = compose_total_stops(0, 2.0) + # log2(2) = 1 + assert 0.95 <= total_stops <= 1.05 + + def test_positive_subject_adds_to_meter(self): + """Positive subject stops increases total.""" + total_1 = compose_total_stops(2.0, 1.0) # meter scale 1.0 = 0 stops + total_2 = compose_total_stops(2.0, 2.0) # meter scale 2.0 = 1 stop + assert total_2 > total_1 + + def test_negative_subject_decreases_total(self): + """Negative subject stops decreases total.""" + total_1 = compose_total_stops(2.0, 2.0) + total_2 = compose_total_stops(-2.0, 2.0) + assert total_2 < total_1 + + def test_meter_scale_one_gives_just_subject(self): + """Meter scale=1.0 (log2=0) → total ≈ subject_stops.""" + subject = 1.5 + total = compose_total_stops(subject, 1.0) + assert 1.45 <= total <= 1.55 + + def test_returns_float(self): + """Returns a float/numeric value.""" + result = compose_total_stops(1.0, 2.0) + assert isinstance(result, (float, np.floating, int)) diff --git a/analyzer/tests/unit/test_folder_inspector.py b/analyzer/tests/unit/test_folder_inspector.py new file mode 100644 index 00000000..89737c67 --- /dev/null +++ b/analyzer/tests/unit/test_folder_inspector.py @@ -0,0 +1,149 @@ +"""Unit tests for folder_inspector.py - folder scanning without ML.""" + +import pytest +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from folder_inspector import inspect_folder, inspect_folders + + +pytestmark = pytest.mark.unit + + +class TestInspectFolder: + """Tests for inspect_folder() function.""" + + def test_empty_folder(self, tmp_path): + """Inspect a folder with no images - returns 0 total.""" + result = inspect_folder(str(tmp_path)) + assert result['total'] == 0 + assert result['has_kestrel'] == False + + def test_raw_files_detected(self, tmp_path): + """Folder with CR3 files - detects them.""" + # Create some fake CR3 file entries + (tmp_path / "IMG_001.CR3").touch() + (tmp_path / "IMG_002.CR3").touch() + + result = inspect_folder(str(tmp_path)) + assert result['total'] == 2 + assert result['has_kestrel'] == False + assert 'root' in result + assert result['root'] == str(tmp_path) + + def test_jpeg_fallback_when_no_raw(self, tmp_path): + """Folder with only JPEGs - detects them as fallback.""" + (tmp_path / "IMG_001.JPG").touch() + (tmp_path / "IMG_002.JPG").touch() + + result = inspect_folder(str(tmp_path)) + assert result['total'] == 2 + + def test_raw_preferred_over_jpeg(self, tmp_path): + """Folder with both RAW and JPEG - RAW takes precedence.""" + (tmp_path / "IMG_001.CR3").touch() + (tmp_path / "IMG_002.CR3").touch() + (tmp_path / "IMG_001.JPG").touch() + (tmp_path / "IMG_002.JPG").touch() + (tmp_path / "IMG_003.JPG").touch() + + result = inspect_folder(str(tmp_path)) + # Should count CR3 files, not JPEGs + assert result['total'] == 2 + + def test_kestrel_dir_normalization(self, tmp_path): + """Passing .kestrel/ dir as input - normalized to parent.""" + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + (tmp_path / "IMG_001.CR3").touch() + + # Inspect the .kestrel/ directory itself + result = inspect_folder(str(kestrel_dir)) + # Should normalize to parent + assert result['root'] == str(tmp_path) + + def test_with_existing_kestrel_analysis(self, tmp_path): + """Folder that has been analyzed - has_kestrel=True and processed count.""" + kestrel_dir = tmp_path / ".kestrel" + kestrel_dir.mkdir() + + # Create minimal CSV + csv_path = kestrel_dir / "kestrel_database.csv" + csv_path.write_text("filename\nIMG_001.CR3\nIMG_002.CR3\n") + + # Create image files + (tmp_path / "IMG_001.CR3").touch() + (tmp_path / "IMG_002.CR3").touch() + + result = inspect_folder(str(tmp_path)) + assert result['has_kestrel'] == True + assert result['processed'] == 2 + assert result['total'] == 2 + + def test_trailing_slash_normalized(self, tmp_path): + """Folder path with trailing slash - handled correctly.""" + (tmp_path / "IMG_001.CR3").touch() + + path_with_slash = str(tmp_path) + "/" + result1 = inspect_folder(path_with_slash) + + path_without_slash = str(tmp_path) + result2 = inspect_folder(path_without_slash) + + # Both should give same root (normalized) + assert result1['root'] == result2['root'] + assert result1['total'] == result2['total'] + + +class TestInspectFolders: + """Tests for inspect_folders() batch function.""" + + def test_single_folder(self, tmp_path): + """Inspect a single folder - returns dict with one entry.""" + (tmp_path / "IMG_001.CR3").touch() + + result = inspect_folders([str(tmp_path)]) + assert len(result) == 1 + assert str(tmp_path) in result + assert result[str(tmp_path)]['total'] == 1 + + def test_multiple_folders(self, tmp_path): + """Inspect multiple folders - returns all results.""" + folder_a = tmp_path / "folder_a" + folder_b = tmp_path / "folder_b" + folder_a.mkdir() + folder_b.mkdir() + + (folder_a / "IMG_001.CR3").touch() + (folder_b / "IMG_002.CR3").touch() + (folder_b / "IMG_003.CR3").touch() + + result = inspect_folders([str(folder_a), str(folder_b)]) + assert len(result) == 2 + assert result[str(folder_a)]['total'] == 1 + assert result[str(folder_b)]['total'] == 2 + + def test_deduplicates_same_path(self, tmp_path): + """Same path listed twice - deduplicates to one result.""" + (tmp_path / "IMG_001.CR3").touch() + + result = inspect_folders([str(tmp_path), str(tmp_path)]) + assert len(result) == 1 + assert str(tmp_path) in result + + def test_shallow_paths_first(self, tmp_path): + """Paths sorted by depth - shallower ones first.""" + deep = tmp_path / "a" / "b" / "c" + shallow = tmp_path / "a" + deep.mkdir(parents=True) + + (shallow / "IMG_001.CR3").touch() + (deep / "IMG_002.CR3").touch() + + result = inspect_folders([str(deep), str(shallow)]) + paths = list(result.keys()) + + # Shallower path should come first + assert paths.index(str(shallow)) < paths.index(str(deep)) diff --git a/analyzer/tests/unit/test_metadata_writer.py b/analyzer/tests/unit/test_metadata_writer.py new file mode 100644 index 00000000..2744c1b9 --- /dev/null +++ b/analyzer/tests/unit/test_metadata_writer.py @@ -0,0 +1,350 @@ +"""Unit tests for metadata_writer.py - XMP sidecar writing.""" + +import pytest +from pathlib import Path +import sys + +sys.path.insert(0, str(Path(__file__).parent.parent.parent)) + +from metadata_writer import write_xmp_metadata, _safe_sidecar_path + + +pytestmark = pytest.mark.unit + + +# XMP namespace constant for matching kestrel-written files +_KESTREL_NS = 'http://ns.projectkestrel.app/xmp/1.0/' + + +class TestWriteBasicXMP: + """Tests for basic XMP writing behavior.""" + + def test_write_basic_xmp_all_fields(self, tmp_path): + """XMP string contains kestrel NS, rating, label, species.""" + # Create a dummy image file (need to exist) + (tmp_path / "IMG_001.CR3").touch() + + result = write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 4, + 'culled': 'accept', + 'culled_origin': 'manual', + 'species': 'aves,columbidae', + 'family': 'columbidae', + 'quality': 0.85 + }] + ) + + assert result['success'] == True + assert result['written'] == 1 + + # Read the XMP file and verify content + xmp_path = tmp_path / "IMG_001.xmp" + assert xmp_path.exists() + content = xmp_path.read_text(encoding='utf-8') + assert _KESTREL_NS in content + assert 'xmp:Rating="4"' in content or '4' in content + assert 'aves' in content + assert 'columbidae' in content + + def test_xmp_label_green_on_manual_accept(self, tmp_path): + """culled='accept', culled_origin='manual' → Green label.""" + (tmp_path / "IMG_001.CR3").touch() + + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 5, + 'culled': 'accept', + 'culled_origin': 'manual', + }] + ) + + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + assert 'Green' in content + + def test_xmp_label_red_on_manual_reject(self, tmp_path): + """culled='reject', culled_origin='manual' → Red label.""" + (tmp_path / "IMG_001.CR3").touch() + + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 1, + 'culled': 'reject', + 'culled_origin': 'manual', + }] + ) + + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + assert 'Red' in content + + def test_no_label_for_auto_origin_without_flag(self, tmp_path): + """culled_origin='auto' without use_auto_labels → no color label.""" + (tmp_path / "IMG_001.CR3").touch() + + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 3, + 'culled': 'reject', + 'culled_origin': 'auto', + }], + use_auto_labels=False + ) + + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + # The Label field should be empty or absent + # Specifically check that neither Green nor Red appears as a label value + assert 'xmp:Label="Red"' not in content + assert 'xmp:Label="Green"' not in content + + def test_auto_labels_when_flag_enabled(self, tmp_path): + """use_auto_labels=True applies labels even for auto-origin culls.""" + (tmp_path / "IMG_001.CR3").touch() + + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 3, + 'culled': 'reject', + 'culled_origin': 'auto', + }], + use_auto_labels=True + ) + + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + assert 'Red' in content + + def test_field_flags_can_disable_species(self, tmp_path): + """fields={'species': False} → kestrel:Species not written.""" + (tmp_path / "IMG_001.CR3").touch() + + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 3, + 'culled': 'accept', + 'culled_origin': 'manual', + 'species': 'aves,columbidae', + 'family': 'columbidae', + 'quality': 0.5 + }], + fields={'rating': True, 'label': True, 'species': False, 'family': True, 'quality': True} + ) + + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + # Species fields should NOT appear + assert 'kestrel:Species' not in content + + +class TestExternalXMPConflict: + """Tests for external XMP conflict handling.""" + + def test_skip_external_xmp_by_default(self, tmp_path): + """Existing XMP without kestrel NS → skipped by default.""" + # Create a fake non-kestrel XMP file + img_path = tmp_path / "IMG_001.CR3" + xmp_path = tmp_path / "IMG_001.xmp" + img_path.touch() + external_xmp = ''' + + + + 3 + + + +''' + xmp_path.write_text(external_xmp, encoding='utf-8') + + result = write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 5, + 'culled': 'accept', + 'culled_origin': 'manual', + }], + overwrite_external=False + ) + + # Should report a skipped conflict + assert 'IMG_001.xmp' in result.get('skipped_conflicts', []) + # Content should be unchanged + content = xmp_path.read_text(encoding='utf-8') + assert '3' in content + + def test_overwrite_external_xmp_when_flag_set(self, tmp_path): + """overwrite_external=True → external XMP gets updated.""" + img_path = tmp_path / "IMG_001.CR3" + xmp_path = tmp_path / "IMG_001.xmp" + img_path.touch() + xmp_path.write_text("external content", encoding='utf-8') + + result = write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 5, + 'culled': 'accept', + 'culled_origin': 'manual', + }], + overwrite_external=True + ) + + assert result['written'] == 1 + # Content should be updated with kestrel XMP + content = xmp_path.read_text(encoding='utf-8') + assert _KESTREL_NS in content + + def test_kestrel_xmp_always_overwritten(self, tmp_path): + """Existing kestrel XMP → updated in place even without overwrite_external.""" + img_path = tmp_path / "IMG_001.CR3" + img_path.touch() + + # Write initial XMP + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 1, + 'culled': 'reject', + 'culled_origin': 'manual', + }] + ) + + # Update with new rating - should not trigger conflict + result = write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 5, + 'culled': 'accept', + 'culled_origin': 'manual', + }], + overwrite_external=False + ) + + assert result['written'] == 1 + assert len(result.get('skipped_conflicts', [])) == 0 + # Verify new rating is in file + xmp_path = tmp_path / "IMG_001.xmp" + content = xmp_path.read_text(encoding='utf-8') + assert '5' in content + # Old rating should not be present (well, the digit 5 is what we want) + + +class TestPathSafety: + """Tests for path traversal protection in XMP writing.""" + + def test_safe_sidecar_path_accepts_normal_filename(self, tmp_path): + """Normal filename → accepted.""" + result = _safe_sidecar_path(str(tmp_path), "IMG_001.CR3") + assert result is not None + assert "IMG_001.CR3" in result + + def test_safe_sidecar_path_rejects_traversal(self, tmp_path): + """Filename with ../ → rejected.""" + result = _safe_sidecar_path(str(tmp_path), "../IMG_001.CR3") + assert result is None + + def test_safe_sidecar_path_rejects_absolute(self, tmp_path): + """Absolute path as filename → rejected.""" + result = _safe_sidecar_path(str(tmp_path), "/etc/passwd") + assert result is None + + def test_safe_sidecar_path_rejects_drive_letter(self, tmp_path): + """Drive letter prefix → rejected.""" + result = _safe_sidecar_path(str(tmp_path), "C:evil.txt") + assert result is None + + def test_safe_sidecar_path_rejects_null_byte(self, tmp_path): + """NUL byte in filename → rejected.""" + result = _safe_sidecar_path(str(tmp_path), "img\x00.txt") + assert result is None + + def test_safe_sidecar_path_rejects_dot(self, tmp_path): + """Filename '.' or '..' → rejected.""" + assert _safe_sidecar_path(str(tmp_path), ".") is None + assert _safe_sidecar_path(str(tmp_path), "..") is None + + def test_safe_sidecar_path_rejects_backslash(self, tmp_path): + """Filename with backslash → rejected.""" + result = _safe_sidecar_path(str(tmp_path), "subdir\\file.txt") + assert result is None + + +class TestXMPContent: + """Tests for XMP content correctness.""" + + def test_xmp_contains_kestrel_namespace(self, tmp_path): + """Written XMP files always contain the Kestrel namespace URI.""" + (tmp_path / "IMG_001.CR3").touch() + write_xmp_metadata( + str(tmp_path), + [{'filename': 'IMG_001.CR3', 'rating': 3, 'culled': 'accept', 'culled_origin': 'manual'}] + ) + content = (tmp_path / "IMG_001.xmp").read_text(encoding='utf-8') + assert _KESTREL_NS in content + + def test_xmp_special_chars_escaped(self, tmp_path): + """XML special chars in species/family → escaped in output.""" + (tmp_path / "IMG_001.CR3").touch() + write_xmp_metadata( + str(tmp_path), + [{ + 'filename': 'IMG_001.CR3', + 'rating': 3, + 'culled': 'accept', + 'culled_origin': 'manual', + 'species': '', + }] + ) + content = (tmp_path / "IMG_001.xmp").read_text(encoding='utf-8') + # The raw @@ -275,6 +293,11 @@

Welcome to Kestrel

15 Rate Space Open in editor M1 RAW Zoom (Scroll to zoom in/out) + Ctrl/Cmd+R Mark as Reviewed + Ctrl/Cmd+T Add species tag + Ctrl/Cmd+Shift+T Add family tag + Ctrl/Cmd+Shift+C Clear all tags + Ctrl/Cmd+Shift+R Reset & reassign tags Esc Close @@ -598,6 +621,7 @@

🔬 Analyze Folders

 outdated version   in progress   not started +   has errors  ● faded = no photos

@@ -636,6 +660,13 @@

📋 Queue Preview

Accelerates detection and classification via DirectML (Windows) or CoreML (macOS).
+
📋 Queue Preview Detects 1,200+ non-bird species (squirrels, deer, bears, etc.) using SpeciesNet.
+
diff --git a/analyzer/visualizer.js b/analyzer/visualizer.js index a7c6402b..4d79de97 100644 --- a/analyzer/visualizer.js +++ b/analyzer/visualizer.js @@ -1,3 +1,11 @@ + // ── Debug-gated console logging ───────────────────────────────────────── + // Verbose diagnostic console.log lines are routed through kdebug() so + // they're silent by default. To enable for a session, open DevTools and + // run: window.__KESTREL_DEBUG = true; location.reload(); + // (console.warn / console.error are NOT gated — they always fire.) + const _KESTREL_DEBUG = !!window.__KESTREL_DEBUG; + function kdebug(...args) { if (_KESTREL_DEBUG) console.log(...args); } + // State (desktop mode only) let rootPath = ''; // Absolute path to root folder (desktop pywebview mode) let rows = []; // CSV rows (objects) @@ -124,13 +132,13 @@ // ───────────────────────────────────────────────────────────────────────── // Debug: Log what APIs are available (initial check) - console.log('[DEBUG] Initial API Detection:'); - console.log(' - Pywebview API (window.pywebview):', hasPywebviewApi); + kdebug('[init] API detection start'); + kdebug(' - Pywebview API (window.pywebview):', hasPywebviewApi); if (hasPywebviewApi) { - console.log(' - window.pywebview object:', window.pywebview); - console.log(' - window.pywebview.api:', window.pywebview.api); + kdebug(' - window.pywebview object:', window.pywebview); + kdebug(' - window.pywebview.api:', window.pywebview.api); if (window.pywebview.api) { - console.log(' - Available API methods:', Object.keys(window.pywebview.api)); + kdebug(' - Available API methods:', Object.keys(window.pywebview.api)); } } @@ -152,9 +160,9 @@ if (found) { hasPywebviewApi = true; el('#compat')?.classList.add('hidden'); - console.log('[DEBUG] Pywebview API ready (elapsed ~' + elapsed + 'ms)'); + kdebug('[init] Pywebview API ready (elapsed ~' + elapsed + 'ms)'); } else { - console.log('[DEBUG] Pywebview API not available after ' + elapsed + 'ms'); + kdebug('[init] Pywebview API not available after ' + elapsed + 'ms'); } resolve(found); } @@ -188,6 +196,10 @@ return; } hasPywebviewApi = true; + // Signal the production-JS-saw-the-bridge proof to --api-probe + // (no-op outside probe mode; report_bridge_ready is side-effect-free + // unless Api._probe_ready_event is set on the Python side). + try { window.pywebview?.api?.report_bridge_ready?.(); } catch (_) { } // After API is ready, check legal agreement checkLegalAgreement(); el('#compat').classList.add('hidden'); @@ -195,6 +207,8 @@ await new Promise(function(r) { setTimeout(r, 500); }); // Hydrate settings from server to ensure localStorage has the latest data await hydrateSettingsFromServer(); + // Load species→family taxonomy map (used for auto-link, cascade, and autocomplete) + loadSpeciesFamilyMap(); // Then check donation threshold (after settings are loaded into localStorage) checkDonationThresholdOnStartup(); })(); @@ -476,7 +490,7 @@ // Display the version update notification as a toast async function showVersionUpdateNotification(versionInfo) { - console.log('[DEBUG] Showing version update notification for version:', versionInfo); + kdebug('[init] Showing version update notification for version:', versionInfo); const toast = document.getElementById('versionUpdateToast'); if (!toast) return; @@ -3050,6 +3064,119 @@ let _activeTagInputType = null; // 'species' or 'family' let _activeTagInputSceneId = null; + // Species → family taxonomy map (loaded once from backend on startup). + // Used to auto-add the family chip when a recognized species is added, + // cascade-remove species when a family chip is X'd, and populate the + // species autocomplete datalist. + let _speciesFamilyMap = null; // { "American Robin": "Thrush sp.", ... } (canonical case) + let _speciesFamilyMapLower = null; // lowercase keys → { canonicalName, family } + let _speciesNameList = []; // sorted array of canonical species names + + async function loadSpeciesFamilyMap() { + if (_speciesFamilyMap) return; + if (!hasPywebviewApi || !window.pywebview?.api?.get_species_family_map) { + _speciesFamilyMap = {}; + _speciesFamilyMapLower = {}; + _speciesNameList = []; + return; + } + try { + const res = await window.pywebview.api.get_species_family_map(); + const map = (res && res.success && res.map) ? res.map : {}; + _speciesFamilyMap = map; + _speciesFamilyMapLower = {}; + for (const [sp, fam] of Object.entries(map)) { + _speciesFamilyMapLower[sp.toLowerCase()] = { canonical: sp, family: fam }; + } + _speciesNameList = Object.keys(map).sort((a, b) => a.localeCompare(b)); + kdebug(`[loadSpeciesFamilyMap] loaded ${_speciesNameList.length} species`); + } catch (e) { + console.warn('[loadSpeciesFamilyMap] failed:', e); + _speciesFamilyMap = {}; + _speciesFamilyMapLower = {}; + _speciesNameList = []; + } + } + + // ── Custom species combobox (replaces native ) ── + // Up to SPECIES_COMBO_MAX visible matches at a time. Arrow keys navigate + // the highlight; Enter commits the highlighted item (or the typed value + // if no highlight); clicking an item commits that item. + const SPECIES_COMBO_MAX = 12; + + /** Filter the species list for a typed query. + * Returns up to SPECIES_COMBO_MAX matches: prefix matches first, then + * substring matches. Empty query returns the alphabetical head of the list. */ + function _filterSpeciesForCombo(query) { + if (!_speciesNameList || _speciesNameList.length === 0) return []; + const q = String(query || '').trim().toLowerCase(); + if (!q) return _speciesNameList.slice(0, SPECIES_COMBO_MAX); + const prefix = []; + const substring = []; + for (const name of _speciesNameList) { + const lower = name.toLowerCase(); + if (lower.startsWith(q)) { + prefix.push(name); + } else if (lower.includes(q)) { + substring.push(name); + } + if (prefix.length >= SPECIES_COMBO_MAX) break; + } + return prefix.concat(substring).slice(0, SPECIES_COMBO_MAX); + } + + /** Render the dropdown list inside the given container element. + * Each row shows the species name and (if known) its family in muted text, + * so the user can confirm the auto-link before committing. */ + function _renderSpeciesComboDropdown(dropdownEl, items, highlightIdx) { + if (!dropdownEl) return; + if (!items || items.length === 0) { + dropdownEl.innerHTML = ''; + dropdownEl.style.display = 'none'; + return; + } + const html = items.map((name, i) => { + const family = (_speciesFamilyMap && _speciesFamilyMap[name]) || ''; + const cls = i === highlightIdx ? 'chip-combo-item chip-combo-item--active' : 'chip-combo-item'; + const fam = family ? `${escapeHtml(family)}` : ''; + return `
${escapeHtml(name)}${fam}
`; + }).join(''); + dropdownEl.innerHTML = html; + dropdownEl.style.display = ''; + } + + /** Look up the family display name for a species name (case-insensitive). + * Returns { canonical, family } if matched, or null for free-form input. */ + function _lookupFamilyForSpecies(name) { + if (!name || !_speciesFamilyMapLower) return null; + const hit = _speciesFamilyMapLower[String(name).trim().toLowerCase()]; + return hit || null; + } + + /** Add a species to the draft and, if the species is in the taxonomy map, + * also add its family. Returns: + * { speciesAdded: bool, speciesValue: string, familyAdded: string|null, matched: bool } + * - speciesValue is the canonical-cased name on match, the input as-typed otherwise. + * - familyAdded is the family display name if it was newly added, else null. + * - matched indicates whether the species was found in the taxonomy map. + */ + function _applySpeciesAutoLink(draft, rawName) { + const name = String(rawName || '').trim(); + if (!name) return { speciesAdded: false, speciesValue: '', familyAdded: null, matched: false }; + const hit = _lookupFamilyForSpecies(name); + const speciesValue = hit ? hit.canonical : name; + const speciesBefore = draft.species.length; + draft.species = Array.from(new Set([...draft.species, speciesValue])).sort(); + const speciesAdded = draft.species.length !== speciesBefore; + let familyAdded = null; + if (hit) { + const famBefore = draft.families.length; + draft.families = Array.from(new Set([...draft.families, hit.family])).sort(); + if (draft.families.length !== famBefore) familyAdded = hit.family; + } + return { speciesAdded, speciesValue, familyAdded, matched: !!hit }; + } + function renderTopbarTags(scene) { const tagsEl = el('#sceneTopbarTags'); if (!tagsEl) return; @@ -3071,7 +3198,7 @@ html += _buildSuggestedTagButton('species', suggestions.species); } if (_activeTagInputType === 'species' && _activeTagInputSceneId === String(scene.id)) { - html += ``; + html += ``; } else { html += ``; } @@ -3124,13 +3251,29 @@ btn.onclick = () => { if (!_sceneEditDraft) _beginSceneEditDraft(scene.id); _sceneEditMode = true; - removeFamilyFromScene(scene, btn.dataset.removeFamily); + const removedFamily = btn.dataset.removeFamily; + removeFamilyFromScene(scene, removedFamily); + // Cascade: also drop any species in the draft whose looked-up family + // matches the removed family. Free-form species (no map entry) are + // left in place — we cannot prove they belong to this family. + let cascaded = 0; + if (_sceneEditDraft && Array.isArray(_sceneEditDraft.species) && removedFamily) { + const before = _sceneEditDraft.species.length; + _sceneEditDraft.species = _sceneEditDraft.species.filter(sp => { + const hit = _lookupFamilyForSpecies(sp); + return !(hit && hit.family === removedFamily); + }); + cascaded = before - _sceneEditDraft.species.length; + } _finalizeSceneReview(scene.id); _sceneEditMode = false; _sceneEditDraft = null; const updatedScene = reloadScene(scene.id) || scene; renderTopbarTags(updatedScene); renderScenes(); + if (cascaded > 0) { + showToast(`Removed family "${removedFamily}" and ${cascaded} associated ${cascaded === 1 ? 'species' : 'species tags'}`, 2200); + } }; }); @@ -3155,20 +3298,25 @@ if (!_sceneEditDraft) _beginSceneEditDraft(scene.id); _sceneEditMode = true; - let changed = false; + let toastMsg = ''; if (suggestType === 'species') { - const before = _sceneEditDraft.species.length; - _sceneEditDraft.species = Array.from(new Set([..._sceneEditDraft.species, suggestValue])).sort(); - changed = _sceneEditDraft.species.length !== before; + const result = _applySpeciesAutoLink(_sceneEditDraft, suggestValue); + if (result.speciesAdded || result.familyAdded) { + toastMsg = result.familyAdded + ? `Added suggested species "${result.speciesValue}" (family: ${result.familyAdded})` + : `Added suggested species "${result.speciesValue}"`; + } } else { const before = _sceneEditDraft.families.length; _sceneEditDraft.families = Array.from(new Set([..._sceneEditDraft.families, suggestValue])).sort(); - changed = _sceneEditDraft.families.length !== before; + if (_sceneEditDraft.families.length !== before) { + toastMsg = `Added suggested family "${suggestValue}"`; + } } - if (changed) { + if (toastMsg) { _finalizeSceneReview(scene.id); - showToast(`Added suggested ${suggestType} "${suggestValue}"`, 2000); + showToast(toastMsg, 2000); } _sceneEditMode = false; @@ -3198,33 +3346,122 @@ }; } - // Wire inline input + // Wire inline input (with combobox behavior for species) const inp = el('#inlineTagInput'); if (inp) { - const commit = () => { - const val = inp.value.trim(); + const dropdown = tagsEl.querySelector('#inlineTagDropdown'); + const isSpeciesInput = _activeTagInputType === 'species'; + // Combobox state — only meaningful for species inputs. + let comboItems = []; + let comboIndex = -1; + + const positionDropdown = () => { + if (!dropdown || !inp) return; + const rect = inp.getBoundingClientRect(); + dropdown.style.left = Math.round(rect.left) + 'px'; + dropdown.style.top = Math.round(rect.bottom + 4) + 'px'; + // Width: at least the input's width, but allow it to grow up to the + // CSS max-width for longer species names. + dropdown.style.minWidth = Math.max(Math.round(rect.width), 240) + 'px'; + }; + + const refreshDropdown = () => { + if (!isSpeciesInput || !dropdown) return; + comboItems = _filterSpeciesForCombo(inp.value); + comboIndex = comboItems.length > 0 ? 0 : -1; + _renderSpeciesComboDropdown(dropdown, comboItems, comboIndex); + if (comboItems.length > 0) positionDropdown(); + }; + + const updateHighlight = (newIdx) => { + if (!comboItems.length) return; + const n = comboItems.length; + comboIndex = ((newIdx % n) + n) % n; + _renderSpeciesComboDropdown(dropdown, comboItems, comboIndex); + // Scroll the active row into view if the dropdown is scrollable. + const active = dropdown?.querySelector('.chip-combo-item--active'); + if (active && active.scrollIntoView) { + active.scrollIntoView({ block: 'nearest' }); + } + }; + + // Guard against re-entry: when commit() runs from Enter and then we + // re-render, the OLD input element is detached from the DOM and its + // onblur fires asynchronously (~150ms) — without this flag, that stale + // blur callback re-invokes this same closure, reading the old input's + // value but using the NEW _activeTagInputType, which can post the + // species value as a family tag. + let committed = false; + const commit = (chosenValue) => { + if (committed) return; + committed = true; + const raw = (chosenValue !== undefined && chosenValue !== null) + ? String(chosenValue) + : inp.value; + const val = raw.trim(); + // Track whether to reopen the family input after a free-form species commit. + let reopenFamilyInput = false; if (val) { if (!_sceneEditDraft) _beginSceneEditDraft(scene.id); _sceneEditMode = true; if (_activeTagInputType === 'species') { - _sceneEditDraft.species = Array.from(new Set([..._sceneEditDraft.species, val])).sort(); + const result = _applySpeciesAutoLink(_sceneEditDraft, val); + _finalizeSceneReview(scene.id); + _sceneEditMode = false; + _sceneEditDraft = null; + if (result.matched) { + if (result.familyAdded) { + showToast(`Added species "${result.speciesValue}" (family: ${result.familyAdded})`, 2200); + } else { + showToast(`Added species "${result.speciesValue}"`, 2000); + } + } else { + // Free-form species (not in taxonomy map). Per design, advance + // focus to the family input so the user can add it next. + showToast(`Added "${result.speciesValue}" — type family next`, 2200); + reopenFamilyInput = true; + } } else { _sceneEditDraft.families = Array.from(new Set([..._sceneEditDraft.families, val])).sort(); + _finalizeSceneReview(scene.id); + _sceneEditMode = false; + _sceneEditDraft = null; + showToast(`Added family "${val}"`, 2000); } - _finalizeSceneReview(scene.id); - _sceneEditMode = false; - _sceneEditDraft = null; - showToast(`Added ${_activeTagInputType} "${val}"`, 2000); } - _activeTagInputType = null; - _activeTagInputSceneId = null; + if (reopenFamilyInput) { + _activeTagInputType = 'family'; + _activeTagInputSceneId = String(scene.id); + } else { + _activeTagInputType = null; + _activeTagInputSceneId = null; + } const updated = reloadScene(scene.id) || scene; renderTopbarTags(updated); renderScenes(); + if (reopenFamilyInput) { + const next = el('#inlineTagInput'); + if (next) next.focus(); + } }; inp.onkeydown = (e) => { - if (e.key === 'Enter') { e.preventDefault(); commit(); } + if (isSpeciesInput && (e.key === 'ArrowDown' || e.key === 'ArrowUp')) { + if (!comboItems.length) return; + e.preventDefault(); + updateHighlight(comboIndex + (e.key === 'ArrowDown' ? 1 : -1)); + return; + } + if (e.key === 'Enter') { + e.preventDefault(); + // If a dropdown row is highlighted, commit that. Otherwise commit + // the typed value (allowing free-form species like "Eurasian Wren"). + const chosen = (isSpeciesInput && comboIndex >= 0 && comboIndex < comboItems.length) + ? comboItems[comboIndex] + : undefined; + commit(chosen); + return; + } if (e.key === 'Escape') { e.preventDefault(); _activeTagInputType = null; @@ -3232,15 +3469,34 @@ renderTopbarTags(scene); } }; + if (isSpeciesInput) { + inp.oninput = () => { refreshDropdown(); }; + inp.onfocus = () => { refreshDropdown(); }; + // Mousedown (not click) so the selection registers BEFORE the input + // loses focus and the blur-commit timer has a chance to fire. + if (dropdown) { + dropdown.onmousedown = (e) => { + const row = e.target.closest('.chip-combo-item'); + if (!row) return; + e.preventDefault(); // keep input focused so blur-commit doesn't race + const idx = parseInt(row.dataset.comboIndex || '-1', 10); + if (idx >= 0 && idx < comboItems.length) { + commit(comboItems[idx]); + } + }; + } + // Initial population on render. + refreshDropdown(); + } inp.onblur = (e) => { - // Small delay to allow clicking the commit button if it exists + // Small delay to allow clicking the commit button or a dropdown row. setTimeout(() => { if (document.activeElement === tagsEl.querySelector('.chip-commit-btn')) return; - if (_activeTagInputType) commit(); + if (_activeTagInputType) commit(); }, 150); }; const commitBtn = tagsEl.querySelector('.chip-commit-btn'); - if (commitBtn) commitBtn.onclick = commit; + if (commitBtn) commitBtn.onclick = () => commit(); } } @@ -3455,6 +3711,51 @@ openSceneDialog(nextScene.id, startIndex); } + // ── Review-flow shortcut helpers (used by _sceneKeyHandler) ── + + /** Mark the current scene as reviewed (same effect as clicking the button). */ + function _markCurrentSceneReviewed() { + if (!_currentScene) return; + _beginSceneEditDraft(_currentScene.id); + _sceneEditMode = true; + _finalizeSceneReview(_currentScene.id); + _sceneEditMode = false; + _sceneEditDraft = null; + const updated = reloadScene(_currentScene.id) || _currentScene; + renderTopbarTags(updated); + renderScenes(); + showToast('Scene tags marked as reviewed', 1800); + } + + /** Open the inline species/family tag input on the current scene and focus it. */ + function _openInlineTagInputForCurrentScene(type) { + if (!_currentScene) return; + if (type !== 'species' && type !== 'family') return; + _activeTagInputType = type; + _activeTagInputSceneId = String(_currentScene.id); + renderTopbarTags(_currentScene); + const inp = el('#inlineTagInput'); + if (inp) inp.focus(); + } + + /** Clear all species and family tags on the current scene (keeps reviewed state). */ + function _clearAllTagsForCurrentScene() { + if (!_currentScene) return; + _beginSceneEditDraft(_currentScene.id); + _sceneEditMode = true; + if (_sceneEditDraft) { + _sceneEditDraft.species = []; + _sceneEditDraft.families = []; + } + _finalizeSceneReview(_currentScene.id); + _sceneEditMode = false; + _sceneEditDraft = null; + const updated = reloadScene(_currentScene.id) || _currentScene; + renderTopbarTags(updated); + renderScenes(); + showToast('Cleared all tags', 1600); + } + // Keyboard handler for scene dialog function _sceneKeyHandler(e) { // Skip if focused in input/textarea (but allow our inline tag input to handle its own Esc/Enter) @@ -3466,6 +3767,47 @@ const len = images.length; const hasSceneModifier = e.ctrlKey || e.metaKey; + const onlyCtrl = hasSceneModifier && !e.shiftKey && !e.altKey; + const ctrlShift = hasSceneModifier && e.shiftKey && !e.altKey; + const lowerKey = (e.key || '').toLowerCase(); + + // ── Review-flow shortcuts ── + // Ctrl+R: mark reviewed. Must preventDefault to suppress browser reload. + if (onlyCtrl && lowerKey === 'r') { + e.preventDefault(); + e.stopPropagation(); + _markCurrentSceneReviewed(); + return; + } + // Ctrl+T: open species tag input. + if (onlyCtrl && lowerKey === 't') { + e.preventDefault(); + e.stopPropagation(); + _openInlineTagInputForCurrentScene('species'); + return; + } + // Ctrl+Shift+T: open family tag input. + if (ctrlShift && lowerKey === 't') { + e.preventDefault(); + e.stopPropagation(); + _openInlineTagInputForCurrentScene('family'); + return; + } + // Ctrl+Shift+C: clear all tags. Must run before the plain-'c' cull-reject branch below. + if (ctrlShift && lowerKey === 'c') { + e.preventDefault(); + e.stopPropagation(); + _clearAllTagsForCurrentScene(); + return; + } + // Ctrl+Shift+R: reset and reassign — clear all tags, then open the species input. + if (ctrlShift && lowerKey === 'r') { + e.preventDefault(); + e.stopPropagation(); + _clearAllTagsForCurrentScene(); + _openInlineTagInputForCurrentScene('species'); + return; + } // Tab skips to next scene; Ctrl/Cmd+Tab (or Shift+Tab) skips to previous if (e.key === 'Tab') { @@ -4737,17 +5079,17 @@ } } catch (_) { } } - console.log('[donation] checkDonationThresholdOnStartup: total =', total); + kdebug('[donation] checkDonationThresholdOnStartup: total =', total); if (total < 1000) { - console.log('[donation] Total < 1000, skipping'); + kdebug('[donation] Total < 1000, skipping'); return; } const thresholds = [1000, 5000, 10000, 25000, 50000, 100000, 200000]; const shown = _loadDonateThresholdsShown(); - console.log('[donation] Thresholds already shown:', shown); + kdebug('[donation] Thresholds already shown:', shown); for (const t of thresholds) { if (total >= t && !shown.includes(t)) { - console.log('[donation] Milestone crossed:', t, '- showing dialog'); + kdebug('[donation] Milestone crossed:', t, '- showing dialog'); shown.push(t); _saveDonateThresholdsShown(shown); // Show dialog after a brief delay to let UI settle @@ -4756,7 +5098,7 @@ } } if (shown.includes(1000)) { - console.log('[donation] 1000 threshold already shown, no dialog needed'); + kdebug('[donation] 1000 threshold already shown, no dialog needed'); } } catch (e) { console.error('[donation] checkDonationThresholdOnStartup error:', e); @@ -5340,7 +5682,8 @@ for (const row of related) { const span = row.querySelector('.tree-count'); row.classList.remove('analyzed-full', 'analyzed-partial', 'analyzed-none', - 'no-photos', 'no-photos-deep', 'no-photos-shallow', 'version-outdated'); + 'no-photos', 'no-photos-deep', 'no-photos-shallow', 'version-outdated', + 'has-errored-images'); row.title = ''; if (span) { span.title = ''; span.textContent = ''; } @@ -5348,11 +5691,18 @@ const totalImgs = info.total; const processedImgs = info.processed; + const erroredImgs = info.errored || 0; if (totalImgs > 0) { - if (span) span.textContent = ` ${processedImgs}/${totalImgs}`; - if (processedImgs >= totalImgs) { - row.classList.add('analyzed-full'); // green: finished + const countText = erroredImgs > 0 + ? ` ${processedImgs}/${totalImgs} (${erroredImgs} errored)` + : ` ${processedImgs}/${totalImgs}`; + if (span) span.textContent = countText; + if (erroredImgs > 0) { + row.classList.add('has-errored-images'); + } + if (processedImgs >= totalImgs && erroredImgs === 0) { + row.classList.add('analyzed-full'); // green: finished, no errors // Check if analyzed on an outdated version const origPath = normToOriginal.get(np) || np; const nodeVer = findNodeVersion(folderTreeRootNode, origPath); @@ -5361,10 +5711,13 @@ row.title = `Analyzed on Kestrel v${nodeVer} (current: v${_appVersion}). Consider re-analyzing.`; } } else if (processedImgs > 0) { - row.classList.add('analyzed-partial'); // purple: started not finished + row.classList.add('analyzed-partial'); // purple: started not finished, OR has errors } else { row.classList.add('analyzed-none'); // blue: has images, not started } + if (erroredImgs > 0) { + row.title = `${erroredImgs} image(s) errored during the previous analysis. Tick "Re-attempt errored images" before queuing to retry just those.`; + } } else { // This folder has 0 images — determine deep vs shallow fading const hasDescendantImages = subtreeHasImages(np); @@ -5844,11 +6197,11 @@ const CONF_LOW = 0.30; /** Call the backend queue API (desktop pywebview mode only). */ - async function apiStartQueue(paths, useGpu = true, wildlifeEnabled = true) { + async function apiStartQueue(paths, useGpu = true, wildlifeEnabled = true, retryErrored = false, speciesDetectionEnabled = true) { if (!window.pywebview?.api?.start_analysis_queue) { throw new Error('Desktop API unavailable: start_analysis_queue'); } - return window.pywebview.api.start_analysis_queue(JSON.stringify(paths), useGpu, wildlifeEnabled); + return window.pywebview.api.start_analysis_queue(JSON.stringify(paths), useGpu, wildlifeEnabled, retryErrored, speciesDetectionEnabled); } async function apiQueueControl(action) { @@ -5917,7 +6270,12 @@ ? recovery.queue_recovery.restore_paths : []; const hasQueueRecovery = restorePaths.length > 0; - const hadUncleanShutdown = !!recovery.unclean_shutdown; + const exitReason = String(recovery.exit_reason || '').toLowerCase(); + // 'os_shutdown' (PC reboot/logoff) and 'clean' never warrant a dialog. + // 'crash' is a real unhandled exception. 'unknown' is ambiguous + // (SIGKILL, power loss, or pre-upgrade install) and gets a soft prompt. + const hadUncleanShutdown = !!recovery.unclean_shutdown + && (exitReason === 'crash' || exitReason === 'unknown' || exitReason === ''); if (!hasQueueRecovery && !hadUncleanShutdown) return; let queueDismissed = false; @@ -5948,9 +6306,10 @@ } if (hadUncleanShutdown) { - const sendReport = confirm( - 'Kestrel detected that the previous session did not shut down cleanly.\n\nSend a crash report now?' - ); + const promptText = exitReason === 'crash' + ? 'Kestrel detected that the previous session crashed.\n\nSend a crash report now?' + : 'Kestrel did not exit cleanly. This is sometimes caused by a system shutdown or power loss — would you still like to send a report?'; + const sendReport = confirm(promptText); if (sendReport) { try { const reportResult = await apiSendRecoveryCrashReport(); @@ -7260,16 +7619,16 @@ // Event wiring el('#pickFolder').addEventListener('click', async () => { - console.log('[DEBUG] Folder picker clicked'); - console.log('[DEBUG] hasPywebviewApi:', hasPywebviewApi); - console.log('[DEBUG] window.pywebview:', window.pywebview); - console.log('[DEBUG] window.pywebview?.api:', window.pywebview?.api); + kdebug('[pickFolder] clicked'); + kdebug('[pickFolder] hasPywebviewApi:', hasPywebviewApi); + kdebug('[pickFolder] window.pywebview:', window.pywebview); + kdebug('[pickFolder] window.pywebview?.api:', window.pywebview?.api); // Wait for pywebview API if it's not ready yet if (!hasPywebviewApi) { - console.log('[DEBUG] Waiting for pywebview API...'); + kdebug('[pickFolder] Waiting for pywebview API...'); const ready = await waitForPywebview(); - console.log('[DEBUG] Pywebview API ready:', ready); + kdebug('[pickFolder] Pywebview API ready:', ready); } // When user opens a folder, reset any checked folders in the main tree // (acts like pressing "Check none") so we don't accidentally load @@ -7284,7 +7643,7 @@ // PRIORITY 1: Python API (desktop app - all platforms) // When available, ALWAYS use this for consistency if (hasPywebviewApi && window.pywebview?.api?.choose_directory) { - console.log('[DEBUG] Using Python API for folder picker'); + kdebug('[pickFolder] Using Python API for folder picker'); try { setStatus('Opening folder picker...'); const folderPath = await window.pywebview.api.choose_directory(); @@ -7655,6 +8014,8 @@ if (paths.length === 0) return; const useGpu = document.getElementById('analyzeUseGpu')?.checked ?? true; const wildlifeEnabled = document.getElementById('analyzeWildlife')?.checked ?? false; + const speciesDetectionEnabled = document.getElementById('analyzeSpeciesDetection')?.checked ?? true; + const retryErrored = document.getElementById('adlgRetryErrored')?.checked ?? true; // Check for outdated-version folders not already confirmed for re-analysis const outdatedPaths = []; @@ -7719,7 +8080,7 @@ const eqVal = ['lenient', 'balanced', 'aggressive'].includes(eqRaw) ? eqRaw : 'balanced'; const modelRaw = String(document.getElementById('adlgWildlifeModelMode')?.value || 'fast').toLowerCase(); const modelVal = modelRaw === 'accurate' ? 'accurate' : 'fast'; - const detectorName = modelVal === 'accurate' ? 'mdv5a' : 'mdv6-e'; + const detectorName = modelVal === 'accurate' ? 'mdv5a' : 'mdv1000-cedar'; const stVal = Math.max(0, parseFloat(document.getElementById('adlgSceneTime')?.value) || 1.0); const ppRaw = parseInt(document.getElementById('adlgParallelPrefetch')?.value, 10); const ppVal = Math.max(1, Math.min(5, Number.isFinite(ppRaw) ? ppRaw : 3)); @@ -7750,7 +8111,7 @@ try { // Show loading overlay while analyzer imports models (lazy-load) showLoadingAnalyzer(); - const result = await apiStartQueue(paths, useGpu, wildlifeEnabled); + const result = await apiStartQueue(paths, useGpu, wildlifeEnabled, retryErrored, speciesDetectionEnabled); if (result && result.success) { queuedFolderPaths.clear(); _dlgSelected.clear(); diff --git a/analyzer/visualizer.py b/analyzer/visualizer.py index 69cd96d6..2b55dfc5 100644 --- a/analyzer/visualizer.py +++ b/analyzer/visualizer.py @@ -1,29 +1,19 @@ #!/usr/bin/env python3 -"""Standalone local web server for the Project Kestrel visualizer (supersedes backend/editor_bridge.py). +"""Project Kestrel application entry point. -Features: - - Serves the existing visualizer.html (and any static assets in the folder). - - Exposes legacy HTTP API endpoints for compatibility while the desktop - pywebview bridge remains the primary integration path. - - Intended to be frozen into a single executable with PyInstaller. +Launches the pywebview desktop window and serves ``visualizer.html`` plus +static assets from a local-only HTTP server (127.0.0.1). All control flows +through the ``api_bridge.Api`` JS bridge; this module owns process startup, +session-lifecycle bookkeeping, OS-shutdown detection, and the crash +handler. Usage (development): python analyzer/visualizer.py --port 8765 --root C:/Photos/Trip + python analyzer/visualizer.py --cli C:/Photos/Trip --no-gpu # headless -After starting it will open the desktop UI (pywebview) at http://127.0.0.1:/ . - -Build single-file EXE (example): - pyinstaller --onefile --name kestrel_viz analyzer/visualizer.py - -Optionally set env vars: - KESTREL_ALLOWED_ROOT=C:/Photos/Trip (restrict paths) - KESTREL_ALLOWED_EXTENSIONS=.cr3,.jpg,... (override allowed editor extensions) - -The legacy HTTP control API (``/settings``, ``/queue/*``, ``/recovery/*``, -``/open``, ``/shutdown``, ``/feedback``) has been removed entirely: all -integration happens through the pywebview JS bridge. The ``KESTREL_ENABLE_LEGACY_*`` -and ``KESTREL_ALLOW_ANY_EXTENSION`` / ``KESTREL_BRIDGE_TOKEN`` environment -variables are no longer recognised. +Optional env vars: + KESTREL_ALLOWED_ROOT=C:/Photos/Trip (jail bridge calls to this root) + KESTREL_ALLOWED_EXTENSIONS=.cr3,.jpg,... (override editor allowlist) """ from __future__ import annotations @@ -46,7 +36,11 @@ from typing import Optional, TextIO # --- Extracted modules --- -from settings_utils import load_persisted_settings, save_persisted_settings, log +from settings_utils import ( + load_persisted_settings, + save_persisted_settings, + debug, info, warn, error, log, +) from queue_manager import _queue_manager from api_bridge import Api @@ -61,16 +55,8 @@ HOST = '127.0.0.1' -# Phase 1 policy lock: the legacy HTTP control surface is permanently disabled. -# All integration flows through the pywebview JS bridge (``api_bridge.Api``). -# These constants are ``False`` literals (not env-derived) so an attacker -# cannot set ``KESTREL_ENABLE_LEGACY_HTTP_API=1`` in the environment to -# re-expose the legacy surface. See SECURITY.md / FINDING-07. -SECURITY_POLICY_VERSION = '2026-04-21' -BROWSER_ONLY_MODE_SUPPORTED = False -API_AUTH_POLICY = 'desktop-api-only;legacy-http-api-removed' -LEGACY_HTTP_API_ENABLED = False -LEGACY_OPEN_ENDPOINT_ENABLED = False +# One-time settings-migration key that flags whether the 2026-03 legal-consent +# self-heal has already run for this install. See ``_apply_legal_upgrade_self_heal``. LEGAL_SELF_HEAL_MIGRATION_KEY = 'legal_upgrade_self_heal_2026_03' # --- Security / behavior configuration --- @@ -164,6 +150,31 @@ def _utc_now_iso() -> str: return datetime.utcnow().isoformat() + 'Z' +# Settings key holding the previous session's outcome. One of: +# 'clean' - normal user-initiated close +# 'os_shutdown' - the OS told us to exit (reboot / logoff / power off) +# 'crash' - unhandled Python exception +# 'unknown' - never updated; ambiguous (e.g. SIGKILL, power loss, +# or an old build that didn't write this key yet) +EXIT_REASON_KEY = 'app_session_exit_reason' +EXIT_REASON_MIGRATION_KEY = 'exit_reason_migrated_v1' + + +def _classify_prior_session(settings: dict) -> str: + """Return the previous session's exit reason, applying legacy migration. + + Pure function — no I/O, safe to call from tests. Reads only from the + provided settings dict; does not mutate it. + """ + reason = str(settings.get(EXIT_REASON_KEY, '') or '').strip().lower() + if reason in ('clean', 'os_shutdown', 'crash', 'unknown'): + return reason + legacy_clean = settings.get('app_session_closed_cleanly', True) + if not bool(legacy_clean) and str(settings.get('app_session_started_utc', '') or '').strip(): + return 'unknown' + return 'clean' + + def _mark_session_start() -> None: """Mark this app session as active and detect unclean prior shutdown. @@ -173,12 +184,20 @@ def _mark_session_start() -> None: """ try: settings = load_persisted_settings() + prev_reason = _classify_prior_session(settings) prev_started = str(settings.get('app_session_started_utc', '') or '').strip() - prev_clean = bool(settings.get('app_session_closed_cleanly', True)) - if prev_started and not prev_clean: + # Only 'crash' and 'unknown' get surfaced as recoverable unclean + # shutdowns. 'os_shutdown' is intentionally suppressed so PC reboots + # don't generate false crash dialogs. + if prev_reason in ('crash', 'unknown') and prev_started: settings['last_unclean_shutdown_utc'] = prev_started + else: + settings.pop('last_unclean_shutdown_utc', None) + settings['last_exit_reason'] = prev_reason + settings[EXIT_REASON_MIGRATION_KEY] = True settings['app_session_started_utc'] = _utc_now_iso() - settings['app_session_closed_cleanly'] = False + settings['app_session_closed_cleanly'] = False # legacy, kept one release + settings[EXIT_REASON_KEY] = 'unknown' settings['app_session_pid'] = int(os.getpid()) try: @@ -203,10 +222,20 @@ def _mark_session_start() -> None: def _mark_session_clean_exit() -> None: - """Mark this session closed cleanly and clear stale unclean-shutdown recovery.""" + """Mark this session closed cleanly and clear stale unclean-shutdown recovery. + + Preserves a previously-recorded 'os_shutdown' or 'crash' reason — the + main() finally block fires after webview.start() returns, which happens + both on user-initiated quit (truly clean) AND when the OS closes our + window during reboot/logoff. In the latter case shutdown_watch has + already recorded 'os_shutdown' and we must not overwrite it. + """ try: settings = load_persisted_settings() settings['app_session_closed_cleanly'] = True + existing_reason = str(settings.get(EXIT_REASON_KEY, '') or '').strip().lower() + if existing_reason not in ('os_shutdown', 'crash'): + settings[EXIT_REASON_KEY] = 'clean' settings['last_session_closed_utc'] = _utc_now_iso() settings.pop('last_unclean_shutdown_utc', None) save_persisted_settings(settings) @@ -214,6 +243,27 @@ def _mark_session_clean_exit() -> None: pass +def _mark_session_exit_reason(reason: str) -> None: + """Atomically record the cause of an in-progress shutdown. + + Called from the OS-shutdown watcher (``shutdown_watch``) and from the + top-level crash handler so the next launch can distinguish a true + application crash from a system reboot or logoff. Failsafe: any I/O + error is swallowed because this runs at the worst possible moment. + """ + if reason not in ('clean', 'os_shutdown', 'crash', 'unknown'): + return + try: + settings = load_persisted_settings() + settings[EXIT_REASON_KEY] = reason + if reason == 'clean': + settings['app_session_closed_cleanly'] = True + settings.pop('last_unclean_shutdown_utc', None) + save_persisted_settings(settings) + except Exception: + pass + + def _apply_legal_upgrade_self_heal(settings: dict, prev_version: str, current_version: str) -> bool: """One-time migrations for legacy installs that lost legal consent markers. @@ -264,16 +314,6 @@ def _apply_legal_upgrade_self_heal(settings: dict, prev_version: str, current_ve return mutated -def build_original_path(*_a, **_k): # pragma: no cover - legacy stub - """Deprecated — callers should use ``api_bridge.Api.open_in_editor``. - - Preserved only so any stale import path short-circuits with a clear - exception instead of silently resurrecting the legacy HTTP ``/open`` - semantics. See FINDING-07. - """ - raise RuntimeError('build_original_path has been removed; use the pywebview API.') - - def _safe_under(base: str, candidate: str) -> bool: """Return True iff ``candidate`` resolves to a path under ``base``. @@ -403,19 +443,6 @@ def end_headers(self): super().end_headers() def do_GET(self): # type: ignore[override] - # bridge_config.js remains for compatibility with any cached front-end - # that still fetches it; it no longer exports a token. - if self.path == '/bridge_config.js': - body = ( - b"// bridge_config.js is deprecated in desktop-only mode\n" - b"window.__BRIDGE_ORIGIN=window.location.origin;\n" - ) - self.send_response(200) - self.send_header('Content-Type', 'application/javascript') - self.send_header('Cache-Control', 'no-store') - self.end_headers() - self.wfile.write(body) - return if self.path in ('/', '/index.html'): # Prefer analyzer/visualizer.html when present (merged layout). # Check multiple locations across dev, frozen, and installed builds. @@ -457,26 +484,6 @@ def _find_visualizer(): self.path = _find_visualizer() return super().do_GET() - def do_OPTIONS(self): # type: ignore[override] - # The legacy HTTP control API has been removed. Reject preflight so no - # third-party page can probe for control routes. See FINDING-07. - log('[security] Reject OPTIONS: legacy HTTP API removed') - self.send_response(405) - self.send_header('Allow', 'GET') - self.end_headers() - - def do_POST(self): # type: ignore[override] - # No POST routes exist any more — every mutation goes through the - # pywebview JS bridge. See FINDING-07. - log('[security] Reject POST: legacy HTTP API removed', self.path) - self.send_response(410) - self.send_header('Content-Type', 'application/json') - self.end_headers() - self.wfile.write( - b'{"ok":false,"error":"Legacy HTTP API has been removed; ' - b'use the pywebview JS bridge instead."}' - ) - def _build_arg_parser(): ap = argparse.ArgumentParser(description='Serve Project Kestrel visualizer with local desktop bridge.') @@ -487,9 +494,235 @@ def _build_arg_parser(): action='store_true', help='Run analyzer CLI mode (headless) instead of launching the desktop UI.', ) + ap.add_argument( + '--api-probe', + dest='api_probe', + action='store_true', + help='Headlessly launch pywebview, evaluate JS to confirm the bridge is reachable, ' + 'write a result JSON to --probe-output, and exit.', + ) + ap.add_argument( + '--probe-output', + dest='probe_output', + type=str, + default=None, + help='Path for the --api-probe result JSON. Required with --api-probe.', + ) + ap.add_argument( + '--probe-timeout', + dest='probe_timeout', + type=float, + default=15.0, + help='Hard timeout in seconds for --api-probe (default 15).', + ) + ap.add_argument( + '--probe-target', + dest='probe_target', + choices=('synthetic', 'visualizer'), + default='synthetic', + help=( + "What --api-probe loads. 'synthetic' (default, fast) uses a minimal " + "probe HTML; 'visualizer' spins up the local HTTP server and loads the " + "real visualizer.html, proving the production JS actually sees " + "window.pywebview.api on the built binary." + ), + ) return ap +def _chdir_to_static_root() -> None: + """Set CWD so the local HTTP server's Handler resolves static asset paths. + + Shared by ``main()`` (full app) and ``_run_api_probe(target='visualizer')`` + so the probe sees exactly the same file layout the real desktop session + does — including the frozen ``_internal/`` bundle when running off a + PyInstaller exe. + """ + if getattr(sys, 'frozen', False): + meipass = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable) + candidate = os.path.join(meipass, '_internal') + if os.path.isdir(candidate): + os.chdir(candidate) + return + if meipass and os.path.isdir(meipass): + os.chdir(meipass) + return + os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') or '.') + + +# Minimal HTML for --api-probe mode. Loads, waits for the pywebview bridge to be +# wired up, then calls Api.report_bridge_ready() which sets a threading.Event on +# the Python side. The bridge call landing IS the proof — no side-channel polling. +_PROBE_HTML = """ + +Kestrel Bridge Probe + + + +

Kestrel Bridge Probe

+
waiting for window.pywebview.api...
+ +""" + + +def _run_api_probe(args) -> int: + """Implements --api-probe: launch a minimal pywebview window, wait for the + JS-Python bridge to round-trip a call, write the result JSON, return exit + code (0 success, 1 failure/timeout, 2 usage error). + """ + import json + import threading + from datetime import datetime, timezone + + def _write_result(path, payload): + try: + with open(path, 'w', encoding='utf-8') as f: + json.dump(payload, f, indent=2) + except Exception as exc: + warn('probe: failed to write result JSON:', exc) + + if not args.probe_output: + sys.stderr.write('--api-probe requires --probe-output PATH\n') + return 2 + + if not WEBVIEW_IMPORT_SUCCESS: + _write_result(args.probe_output, { + 'ok': False, + 'error': 'pywebview unavailable (import failed)', + 'timestamp': datetime.now(timezone.utc).isoformat(), + }) + return 1 + + api = Api() + api._probe_ready_event = threading.Event() + api._probe_ready_payload = None + + timeout = max(1.0, float(args.probe_timeout)) + final_payload = {'ok': False, 'error': 'probe did not start'} + target = getattr(args, 'probe_target', 'synthetic') or 'synthetic' + + # In 'visualizer' mode we stand up the same local HTTP server the real + # desktop session uses and point pywebview at it, so the probe exercises + # the production visualizer.html + visualizer.js bundle. The signal that + # the bridge wired up still comes from JS calling Api.report_bridge_ready + # — we just added one such call in visualizer.js for this purpose. + server = None + server_thread = None + if target == 'visualizer': + try: + _chdir_to_static_root() + server = ThreadingHTTPServer((HOST, args.port), Handler) + except Exception as exc: + _write_result(args.probe_output, { + 'ok': False, + 'error': f'probe: HTTP server bind failed on port {args.port}: ' + f'{type(exc).__name__}: {exc}', + 'timestamp': datetime.now(timezone.utc).isoformat(), + }) + return 1 + + def _serve(): + try: + server.serve_forever() + except Exception: + pass + + server_thread = threading.Thread(target=_serve, daemon=True) + server_thread.start() + + try: + if target == 'visualizer': + win = webview.create_window( + 'Project Kestrel (probe)', + url=f'http://{HOST}:{args.port}/', + js_api=api, + width=900, + height=600, + ) + else: + win = webview.create_window( + 'Project Kestrel (probe)', + html=_PROBE_HTML, + js_api=api, + width=400, + height=300, + ) + except Exception as exc: + if server is not None: + try: + server.shutdown() + server.server_close() + except Exception: + pass + _write_result(args.probe_output, { + 'ok': False, + 'error': f'create_window failed: {type(exc).__name__}: {exc}', + }) + return 1 + + def _waiter(): + # Runs on a worker thread (started via webview.start(func=...)). + # Blocks until the JS side calls Api.report_bridge_ready, or until the + # hard deadline elapses. Then writes the result JSON and destroys the + # window so webview.start() returns control to main(). + nonlocal final_payload + ok = api._probe_ready_event.wait(timeout=timeout) + if ok and api._probe_ready_payload is not None: + final_payload = dict(api._probe_ready_payload) + final_payload.setdefault('probe_target', target) + else: + final_payload = { + 'ok': False, + 'error': f'bridge did not report ready within {timeout:.1f}s', + 'probe_target': target, + 'timestamp': datetime.now(timezone.utc).isoformat(), + } + _write_result(args.probe_output, final_payload) + try: + webview.destroy_window(win) + except Exception: + try: + win.destroy() + except Exception: + pass + + try: + webview.start(func=_waiter, debug=False) + except Exception as exc: + _write_result(args.probe_output, { + 'ok': False, + 'error': f'webview.start failed: {type(exc).__name__}: {exc}', + }) + return 1 + finally: + if server is not None: + try: + server.shutdown() + server.server_close() + except Exception: + pass + + return 0 if final_payload.get('ok') else 1 + + def parse_args(): return _build_arg_parser().parse_args() @@ -500,6 +733,8 @@ def parse_known_args(): def main(): args, remaining_args = parse_known_args() + if args.api_probe: + sys.exit(_run_api_probe(args)) if args.cli: from cli import main as cli_main @@ -509,10 +744,17 @@ def main(): runtime_log_path = _enable_runtime_log_capture() if runtime_log_path: log('Runtime log capture enabled:', runtime_log_path) - log('Security policy:', SECURITY_POLICY_VERSION, API_AUTH_POLICY, 'browser_mode_supported=', BROWSER_ONLY_MODE_SUPPORTED) - log('Legacy HTTP control API: removed (desktop-only). env-var escape hatches are no longer honoured.') _mark_session_start() + # Listen for OS-initiated shutdown / logoff / reboot so the next launch + # can distinguish a system-driven exit from a real application crash and + # suppress the false unclean-shutdown dialog. Best-effort and failsafe. + try: + import shutdown_watch + shutdown_watch.install(lambda: _mark_session_exit_reason('os_shutdown')) + except Exception as _e: + warn('shutdown_watch install failed:', _e) + # ── Crash hardening ─────────────────────────────────────────────────────── # faulthandler dumps a Python traceback to stderr (which is tee-streamed to # the runtime log file) on SIGSEGV / SIGABRT / hard crashes from native libs @@ -530,8 +772,9 @@ def _thread_excepthook(args): import traceback as _tb thread_name = getattr(args.thread, 'name', 'unknown') tb_str = ''.join(_tb.format_exception(args.exc_type, args.exc_value, args.exc_traceback)) - log(f'[Thread {thread_name!r}] Uncaught exception: {args.exc_type.__name__}: {args.exc_value}') - log(f'[Thread {thread_name!r}] Traceback:\n{tb_str}') + error(f'[Thread {thread_name!r}] Uncaught exception: {args.exc_type.__name__}: {args.exc_value}') + error(f'[Thread {thread_name!r}] Traceback:\n{tb_str}') + _mark_session_exit_reason('crash') if _telemetry is not None: try: _telemetry.send_crash_report( @@ -539,6 +782,7 @@ def _thread_excepthook(args): tb_str=tb_str, machine_id=_telemetry.get_machine_id(load_persisted_settings()), version=_telemetry._read_version(), + exit_reason='crash', ) except Exception: pass @@ -551,22 +795,9 @@ def _thread_excepthook(args): # When visualizer.py is run from inside analyzer/ (merged layout) set # the working directory to the repository root so assets and shared - # files (assets/, visualizer files) are served correctly. - # If frozen by PyInstaller (onedir), prefer the bundled _internal folder - # inside the distribution so static assets (visualizer.html, logos) are - # served from the on-disk bundle. - if getattr(sys, 'frozen', False): - meipass = getattr(sys, '_MEIPASS', None) or os.path.dirname(sys.executable) - candidate = os.path.join(meipass, '_internal') - if os.path.isdir(candidate): - os.chdir(candidate) - elif meipass and os.path.isdir(meipass): - os.chdir(meipass) - else: - # Fallback to repo-root relative when running unpacked - os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') or '.') - else: - os.chdir(os.path.join(os.path.dirname(os.path.abspath(__file__)), '..') or '.') + # files (assets/, visualizer files) are served correctly. The frozen + # PyInstaller branch prefers the bundled _internal/ folder. + _chdir_to_static_root() server = ThreadingHTTPServer((HOST, args.port), Handler) log(f'Serving visualizer at http://{HOST}:{args.port}/ (Press Ctrl+C to stop)') log('HTTP surface: static-file GET only. Control routes permanently removed.') @@ -625,7 +856,7 @@ def _cleanup_preview_cache_before_exit(): if hasattr(api, 'cleanup_tracked_culling_caches'): api.cleanup_tracked_culling_caches() except Exception as e: - log('Cache cleanup on close failed:', e) + warn('Cache cleanup on close failed:', e) def _cancel_analysis_wait_for_worker_and_telemetry(): """Cancel queue, wait for worker (sends completion telemetry), then allow HTTP to finish.""" @@ -751,12 +982,12 @@ def _cancel_analysis_wait_for_worker_and_telemetry(): if api is not None and hasattr(api, 'cleanup_tracked_culling_caches'): api.cleanup_tracked_culling_caches() except Exception as e: - log('Cache cleanup during shutdown failed:', e) + warn('Cache cleanup during shutdown failed:', e) try: server.shutdown() server.server_close() except Exception as e: - log('Server shutdown error:', e) + warn('Server shutdown error:', e) log('Server stopped.') # Mark clean exit here (inside finally) so it runs even if server # shutdown raises, preventing a false "unclean shutdown" on next launch. @@ -770,25 +1001,27 @@ def _cancel_analysis_wait_for_worker_and_telemetry(): _mark_session_clean_exit() except Exception as _main_exc: # Top-level crash handler — send crash report before re-raising + _mark_session_exit_reason('crash') try: import traceback as _tb if _telemetry is not None: _crash_settings = load_persisted_settings() _crash_mid = _telemetry.get_machine_id(_crash_settings) - + # Fetch recent log tail, passing the active folder's log if available _folder_path = _crash_settings.get('active_analysis_path', '') if _folder_path: _log_tail = _telemetry.get_recent_log_tail(folder=_folder_path, runtime_log_files=3) else: _log_tail = _telemetry.get_recent_log_tail(runtime_log_files=3) - + _telemetry.send_crash_report( exc=_main_exc, tb_str=_tb.format_exc(), log_tail=_log_tail, machine_id=_crash_mid, version=_telemetry._read_version(), + exit_reason='crash', ) # Give daemon thread a moment to fire off the HTTP request import time as _t diff --git a/packaging/ProjectKestrel.appxmanifest b/packaging/ProjectKestrel.appxmanifest index 2d572dea..66844274 100644 --- a/packaging/ProjectKestrel.appxmanifest +++ b/packaging/ProjectKestrel.appxmanifest @@ -8,7 +8,7 @@ + Version="5.7.0.0" /> Project Kestrel diff --git a/requirements-macos.txt b/requirements-macos.txt index 742975ea..7520e0a9 100644 --- a/requirements-macos.txt +++ b/requirements-macos.txt @@ -4,28 +4,25 @@ # acceleration via Apple Core ML. No separate package needed (unlike DirectML on Windows). onnxruntime==1.23.2 -numpy==2.1.3 +numpy==2.4.4 # HTTP and SSL dependencies certifi # Computer Vision opencv-python==4.11.0.86 -rawpy==0.26.1 +rawpy==0.27.0 # Data Processing pandas==2.2.2 -# GUI Framework -PyQt6==6.10.2 - # Utilities PyExifTool==0.5.6 -requests==2.33.0 -pywebview==6.1 +requests==2.34.2 +pywebview==6.2.1 # installer -pyinstaller==6.18.0 +pyinstaller==6.20.0 # EXIF reading exifread diff --git a/requirements-windows.txt b/requirements-windows.txt index dc907a0f..877a1fee 100644 --- a/requirements-windows.txt +++ b/requirements-windows.txt @@ -15,25 +15,22 @@ # the 1.23.2 pin used for macOS / local dev (same minor series). onnxruntime-directml==1.24.4 -numpy==2.1.3 +numpy==2.4.4 # Computer Vision opencv-python==4.11.0.86 -rawpy==0.26.1 +rawpy==0.27.0 # Data Processing pandas==2.2.2 -# GUI Framework -PyQt6==6.10.2 - # Utilities PyExifTool==0.5.6 -requests==2.33.0 -pywebview==6.1 +requests==2.34.2 +pywebview==6.2.1 # installer -pyinstaller==6.18.0 +pyinstaller==6.20.0 msvc-runtime==14.44.35112 # EXIF reading diff --git a/requirements.txt b/requirements.txt index 43f1c1ce..42adcd34 100644 --- a/requirements.txt +++ b/requirements.txt @@ -3,26 +3,22 @@ # acceleration on Windows (any DX12-compatible GPU). They are mutually exclusive. onnxruntime==1.23.2 -numpy==2.1.3 +numpy==2.4.4 # Computer Vision opencv-python==4.11.0.86 -rawpy==0.26.1 +rawpy==0.27.0 # Data Processing pandas==2.2.2 -# GUI Framework -PyQt6==6.10.2 - # Utilities PyExifTool==0.5.6 -requests==2.33.0 -pywebview==6.1 +requests==2.34.2 +pywebview==6.2.1 # installer -pyinstaller==6.18.0 -msvc-runtime==14.44.35112 +pyinstaller==6.20.0 # EXIF reading exifread diff --git a/scripts/resave_quality_model.py b/utils/resave_quality_model.py similarity index 100% rename from scripts/resave_quality_model.py rename to utils/resave_quality_model.py