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
@@ -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