diff --git a/CHANGELOG.md b/CHANGELOG.md index 4841bc5e..b8aac949 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -9,8 +9,39 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [7.1.0] - 2026-09-08 + +Release da primeira execucao. A 7.0.1 fez o programa instalar e abrir sem +terminal; operando o resultado numa maquina limpa, tres lacunas apareceram -- +todas no intervalo entre o duplo-clique e a primeira analise. + ### Added +- **Janela de primeiros passos**, mostrada depois que a janela principal abre. + Ate aqui o operador recebia um aplicativo completo sem nenhuma indicacao do + que fazer primeiro -- e, em particular, nenhuma pista de que os seis modelos + precisam ser atribuidos a papeis antes de o rastreamento se comportar bem. + + Ela explica os modelos, os quatro papeis que eles preenchem e quando vale + ligar o OpenVINO, e oferece abrir o painel onde essas escolhas sao feitas. + **O conselho sobre OpenVINO e lido da maquina, nao escrito no abstrato:** + "use OpenVINO quando nao houver placa NVIDIA" e verdade e inutil para quem nao + sabe o que tem instalado, entao o texto consulta `get_hardware_summary()` e + nomeia os dispositivos encontrados. + + A caixa "nao mostrar novamente" grava **apenas** `ui.show_welcome` via + `write_local_override()`. Nunca `save_settings`, que despejaria a arvore + inteira de defaults em `config.local.yaml` e derrotaria de vez a fusao em + camadas `config.yaml` -> `config.local.yaml` naquela instalacao. + +- **Entradas de menu para as definicoes de modelo e para os primeiros passos.** + O painel de configuracao de modelos **nao tinha entrada de menu nenhuma**: + era alcancavel so por um botao dentro da visao de projeto, ou seja, a unica + tela que o operador precisa antes da primeira analise era a que ele nao + conseguia achar. Agora esta em **Configuracoes -> Definicoes de modelo...**, + e a janela de boas-vindas volta por **Ajuda -> Primeiros passos...**, de modo + que dispensa-la em definitivo nao torna a explicacao inalcancavel. + - **Instalador guiado e atalho de area de trabalho.** Quem opera o programa nao precisa mais abrir um terminal para usa-lo. `install.ps1` (duplo-clique via `install.bat`) confere o Python, confere o Poetry, fixa o ambiente no @@ -35,6 +66,52 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Fixed +- **O duplo-clique passava ~4 s sem sinal de vida.** O splash era criado + *depois* do bloco de imports que monta o grafo de coordinators, entao a janela + so aparecia quando o carregamento pesado ja tinha terminado. Sob o atalho + (`pythonw.exe`, sem console) isso e indistinguivel de um icone morto. + + Dois custos estavam na frente dele, ambos com cara de `import` comum: + `from zebtrack.utils import set_seed` -- `zebtrack.utils` importa torch em + corpo de modulo, ~1,1 s -- e o bloco `di_registrations`, que puxa ultralytics, + cv2 e matplotlib por cima do torch, ~3 s. Medido em `logs/analysis.log` entre + `application.starting` e `benchmark.running_first_time`. + + O splash agora e construido logo apos `i18n.install()`, antes dos dois, e o + carregamento pesado roda sob a mensagem "Carregando o motor de rastreamento". + `tests/core/test_app_runner_splash_ordering.py` fixa a **ordem**, nao a + duracao: uma assercao de tempo seria instavel numa maquina carregada e nao + diria por que regrediu. + +- **As etapas do benchmark nunca eram legiveis no splash.** Elas sempre foram + enviadas para la, mas varias terminam em milissegundos: a mensagem era + pintada e sobrescrita dentro do mesmo quadro, entao a sequencia so existia no + log. Cada etapa agora permanece um instante minimo na tela + (`SPLASH_MIN_STEP_SECONDS`), pulado quando a etapa ja demorou mais que isso. + +- **O benchmark da primeira execucao nao media nada -- e cacheava o resultado.** + As etapas 3 a 5 sao guardadas por `if video_path and model_path and + profile.openvino_available`, e numa instalacao nova `openvino_model_cache/` + ainda nao existe, entao `_find_openvino_model()` devolvia `None` e **todas** + eram puladas. O benchmark inteiro terminava em 0,2 s e recomendava CPU a 0,0 + FPS; esse resultado ia para o cache e nunca mais era recalculado, deixando a + maquina configurada por uma medicao que jamais aconteceu. + + Agora ele **converte um modelo para OpenVINO antes de medir** (nova etapa 2 de + 7). Nao e sobrecarga acrescentada: e trabalho que a aplicacao pagaria de + qualquer forma no primeiro uso real do OpenVINO -- fazer aqui tira do caminho + da primeira analise e coloca num momento em que o splash pode explicar a + espera. E um resultado que nao mediu nada e marcado como inconclusivo e + **nao** vai para o cache: repetir a tentativa no proximo arranque custa + segundos; congelar a resposta errada custa a vida da instalacao. + +- **Os caminhos do benchmark eram relativos ao diretorio de trabalho.** + `get_benchmark_cache_path()`, `_find_test_video()` e `_find_openvino_model()` + resolviam contra o CWD. Pelo atalho isso concordava por sorte -- o `.lnk` fixa + `WorkingDirectory` na pasta do repositorio -- mas iniciado de qualquer outro + lugar, `--reset` apagava um arquivo e a aplicacao escrevia outro. Todos + ancorados em `repo_root()` agora, como o resto do projeto. + - **Sem console, o logging morria a cada registro.** `configure_logging` construia `StreamHandler(sys.stdout)` incondicionalmente. Sob `pythonw.exe` -- exatamente como o atalho novo abre o app -- `sys.stdout` e None, e @@ -87,6 +164,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- **A instalacao passou a trazer os seis modelos, nao quatro.** `best_oi.pt` e + `best_seg.pt` estavam marcados `required: false` no manifesto e ficavam atras + de `fetch-weights --all`. + + A razao registrada era circular: eles ficavam de fora do download **porque** + `WeightManager` nao sabia descobri-los. A descoberta varria so + `best_*_lateral.pt` e `best_*_topdown.pt`, entao os dois generalistas caiam em + `weights/` e nunca apareciam no catalogo -- baixa-los sem isso seria buscar um + arquivo que nada conseguia selecionar. O defeito real era a lacuna de + descoberta; ela foi corrigida (`discover_weights()` varre `best_*.pt`), e o + download deixou de precisar modelar o problema. + + **Um generalista nunca toma um slot padrao.** Os especialistas sao treinados + para um angulo de camera especifico -- um modelo lateral numa cena de topo nao + devolve nada -- entao eles continuam sendo os padroes, e o generalista e algo + que o pesquisador escolhe no painel. O download passou de ~212 MB para + ~240 MiB; `--all` continua aceito e agora nao muda nada. + - **A documentacao de instalacao passou a ter duas trilhas explicitas**, operador e desenvolvedor, em vez de uma so escrita para quem desenvolve. A trilha do operador nao pede Git (ZIP da pagina de releases) e diz **onde obter** Python e diff --git a/CITATION.cff b/CITATION.cff index 9ee5480b..40fbad22 100644 --- a/CITATION.cff +++ b/CITATION.cff @@ -13,8 +13,8 @@ authors: email: "percilia.giaquinto@unesp.br" affiliation: "São Paulo State University (UNESP), Botucatu, Brazil" title: "DRerio LogAI" -version: 7.0.1 -date-released: "2026-09-07" +version: 7.1.0 +date-released: "2026-09-08" abstract: "An open-source, GPU-free platform for automated tracking, closed-loop stimulation and behavioural analysis of adult zebrafish (Danio rerio), combining YOLOv11 detection, BYTETracker multi-object tracking and Intel OpenVINO inference in an event-driven architecture." keywords: - "zebrafish" diff --git a/README.md b/README.md index 1fa1a5b6..c3285881 100644 --- a/README.md +++ b/README.md @@ -315,7 +315,7 @@ on a machine without a toolchain — reporting, one command later, that the **Disk space is the requirement that surprises people.** PyTorch, OpenVINO, OpenCV and SciPy alone account for most of a ~1.7 GB virtual environment, and the -detector weights add another ~200 MB. +detector weights add another ~250 MB. ### Installing to use it @@ -355,7 +355,7 @@ procedure.** It needs neither Git nor a terminal session you have to keep open. does the same and makes updating easier.) 4. **Run the installer.** Open the extracted folder and **double-click - `install.bat`**. It installs the dependencies, downloads the ~200 MB of + `install.bat`**. It installs the dependencies, downloads the ~250 MB of detector models, and puts a **DRerio LogAI** icon on your Desktop and in the Start Menu. Expect several minutes. @@ -394,7 +394,7 @@ poetry install --with dev # Install pre-commit hooks poetry run pre-commit install -# Download the detector models (~200 MB, required to run the app) +# Download the detector models (~250 MB, required to run the app) poetry run fetch-weights # Run the tests to verify the installation diff --git a/README.pt-BR.md b/README.pt-BR.md index 626cdc4c..47c22d12 100644 --- a/README.pt-BR.md +++ b/README.pt-BR.md @@ -318,7 +318,7 @@ numa máquina sem toolchain — avisando, um comando depois, que o módulo **Espaço em disco é o requisito que costuma surpreender.** PyTorch, OpenVINO, OpenCV e SciPy sozinhos respondem pela maior parte de um ambiente virtual de -~1,7 GB, e os pesos do detector somam outros ~200 MB. +~1,7 GB, e os pesos do detector somam outros ~250 MB. ### Instalando para usar @@ -358,7 +358,7 @@ inteiro.** Não exige Git nem manter um terminal aberto. faz o mesmo e facilita atualizar depois.) 4. **Rode o instalador.** Abra a pasta extraída e dê **duplo-clique em - `install.bat`**. Ele instala as dependências, baixa os ~200 MB de modelos do + `install.bat`**. Ele instala as dependências, baixa os ~250 MB de modelos do detector e coloca um ícone **DRerio LogAI** na Área de Trabalho e no Menu Iniciar. Leva alguns minutos. @@ -397,7 +397,7 @@ poetry install --with dev # Instale os hooks de pré-commit poetry run pre-commit install -# Baixe os modelos do detector (~200 MB, obrigatórios para rodar o app) +# Baixe os modelos do detector (~250 MB, obrigatórios para rodar o app) poetry run fetch-weights # Execute os testes para verificar a instalação diff --git a/config.yaml b/config.yaml index f5856df6..a6b47779 100644 --- a/config.yaml +++ b/config.yaml @@ -94,11 +94,10 @@ yolo_model: # This path is used as fallback; actual model selection happens via model_selection section # Path is relative to the project root; weights live under weights.source_dir. # - # Points at a weight `fetch-weights` installs by DEFAULT. It used to name - # `best_seg.pt`, which is published but sits behind `--all`, so a correctly - # provisioned machine still logged `yolo_model.path.not_found` on every start - # -- a permanent false alarm that trained everyone to ignore the one warning - # that means the weights are really missing. + # Points at a perspective-specific weight rather than the generalist + # `best_seg.pt`. Both are installed now -- `fetch-weights` fetches all six -- + # but a lateral-trained model is the better default: the generalist is + # something the researcher opts into from the model configuration panel. path: 'weights/best_seg_lateral.pt' # Confidence threshold for the ARENA (aquarium) detection. @@ -452,6 +451,12 @@ ui: # The operating system locale is deliberately NOT consulted. language: en + # Show the getting-started window once the main window is up. It explains the + # six detector models, the four roles they fill, and when OpenVINO helps. + # The window's "do not show again" checkbox writes this key (and only this + # key) to config.local.yaml; it stays reachable from Help -> Getting Started. + show_welcome: true + # ----------------------------------------------------------------------------- # Performance & Parallelization (Phase 8) # ----------------------------------------------------------------------------- diff --git a/docs/releases/RELEASE_NOTES_v7.1.0.md b/docs/releases/RELEASE_NOTES_v7.1.0.md new file mode 100644 index 00000000..b5a84b33 --- /dev/null +++ b/docs/releases/RELEASE_NOTES_v7.1.0.md @@ -0,0 +1,130 @@ +# DRerio LogAI v7.1.0 + +The first-run release. v7.0.0 made the software citable; the work since then has +been about the interval between double-clicking the icon and starting the first +analysis — the part a researcher meets before any of the science. + +This release supersedes the unreleased 7.0.1 entry in the changelog: v7.0.0 is +the only tag published so far, so everything below has accumulated since it. + +## Relation to the published results + +Unchanged from v7.0.0. The validation and benchmark results reported in the +associated manuscripts were produced with release **4.0.0**, available as tag +`v4.0.0`, which remains the version to check out for exact reproduction. Releases +5 through 7 are subsequent work on the codebase and were not used to generate any +reported result. + +## Installing this release + +On Windows, download the source ZIP from this page and double-click +`install.bat`. It checks Python, checks Poetry, pins the environment to a +supported interpreter, installs the dependencies, downloads the models and +creates a **DRerio LogAI** icon on the Desktop and in the Start Menu. + +From a terminal, on any platform: + +```bash +git clone https://github.com/MarkSant/DRerio-LogAI.git && cd DRerio-LogAI +poetry install +poetry run fetch-weights # ~240 MiB of trained models, required +poetry run zebtrack +``` + +No C compiler is needed any more — every dependency installs from a prebuilt +wheel. Budget about 3 GB of free disk for the virtual environment and the models +together. Python 3.12 or 3.13; **not** 3.14, where the pinned numpy has no wheel. + +### All six models are now installed + +`fetch-weights` downloads **all six** trained models and verifies every file +against a SHA-256 recorded in `weights_manifest.json`. Previously it installed +four and left the two generalists — `best_oi.pt` and `best_seg.pt` — behind an +`--all` flag. + +The reason they were excluded turned out to be circular. They were skipped +*because* the weight catalogue could not discover them: discovery globbed only +`best_*_lateral.pt` and `best_*_topdown.pt`, so a downloaded generalist landed in +`weights/` and never appeared in the model panel. Fetching a file nothing could +select was pointless. The discovery gap was the real defect; with it fixed, the +download no longer has to model it. `--all` is still accepted and now changes +nothing. + +The four specialists remain the defaults. They are trained for a specific camera +angle — a lateral model on a top-down scene returns nothing — so a generalist is +registered but never claims a default slot; it is something you opt into from the +model panel. + +**The model assets for this release are the same files published on `v7.0.0`, +byte for byte**, and `weights_manifest.json` still points there. That is +deliberate: the manifest names a published release, so `fetch-weights` works +immediately rather than depending on this page. + +## What changed + +### The icon no longer looks dead for four seconds + +The splash screen was created *after* the import block that builds the +coordinator graph, so the window only appeared once the heavy loading had already +finished. Under the desktop shortcut the app runs through `pythonw.exe` with no +console, so there was nothing at all to see: no window, no cursor change, no +taskbar entry. + +Two costs sat in front of it, both looking like ordinary import lines — +`zebtrack.utils` imports torch in its module body (~1.1 s), and the DI +registration block pulls ultralytics, cv2 and matplotlib on top of it (~3 s). +The splash is now built before both, and the heavy loading runs under a progress +message. + +### The first-run benchmark now measures something + +Its inference steps are guarded on an OpenVINO model existing, and on a fresh +install nothing has been converted yet — so every measurement step was skipped. +The whole benchmark finished in 0.2 seconds and recommended CPU at 0.0 FPS. That +result was then cached and never recomputed, leaving the machine configured from +a measurement that never happened. + +It now converts one model to OpenVINO before measuring. This is not added +overhead: it is work the application pays anyway the first time OpenVINO is used +for real, moved out of your first analysis and into a moment where the splash can +explain the wait. A run that still measures nothing is marked inconclusive and is +**not** cached, so it retries on the next launch instead of freezing a guess. + +The seven benchmark steps are also legible now. They were always sent to the +splash, but several finish in milliseconds and were overwritten within a single +frame, so the sequence existed only in the log. + +### A getting-started window, and a menu entry that should have existed + +Opening the application for the first time gave you a complete interface and no +indication of what to do first — in particular, no hint that the six models need +assigning to roles. Worse, the model configuration panel had **no menu entry at +all**: it was reachable only from a button inside a project view. + +There is now a getting-started window after the main window opens, explaining the +models, the four roles they fill, and whether OpenVINO is worth enabling on +*your* machine — it reads the detected hardware and names the devices rather than +offering generic advice. It is reachable afterwards from **Help → Getting +Started**, and the panel itself from **Settings → Model settings**. + +### Installing and launching without a terminal (from the unreleased 7.0.1 work) + +- `install.ps1` / `install.bat` and `setup.sh`: guided installation that stops at + the first failing step and says what to do about it. +- A desktop and Start Menu shortcut targeting `pythonw.exe -m zebtrack`. +- **Logging died on every record without a console.** `StreamHandler(sys.stdout)` + under `pythonw` got `None`, and the fallback to `sys.stderr` is `None` too — + every record raised inside `emit`, silently. +- **Camera detection needed a stderr to silence.** A failed redirect closed the + process's real stderr; the wizard then listed no cameras at all. +- **The very first launch hung on a window that was never drawn.** The language + chooser called `transient()` on a withdrawn root, and a transient inherits its + master's withdrawn state — so `wait_window` blocked forever on a window nobody + could see, focus or close. +- **A failure before Tk existed vanished without trace.** Startup errors now + reach a message box instead of a `sys.stderr` that does not exist. + +## Full changelog + +See [`CHANGELOG.md`](../../CHANGELOG.md) for the complete list with the reasoning +behind each change. diff --git a/docs/tasks/active/REFACTOR_GIANTS.md b/docs/tasks/active/REFACTOR_GIANTS.md index 027cfeb3..6b5bba1b 100644 --- a/docs/tasks/active/REFACTOR_GIANTS.md +++ b/docs/tasks/active/REFACTOR_GIANTS.md @@ -193,7 +193,7 @@ top-level helpers and 1 exception class. Concerns: | Concern | Approx. line range | Marker methods | | ----------------------------- | ------------------ | ----------------------------------------------------------------------------- | -| Loading & discovery | 145–520 | `_resolve_weights_dir`, `_load_weights`, `discover_perspective_weights`, `_maybe_relocate_path` | +| Loading & discovery | 145–520 | `_resolve_weights_dir`, `_load_weights`, `discover_weights`, `_maybe_relocate_path` | | Classification | 391–435 | `_classify_weight_type`, `_classify_perspective` | | Persistence | 522–650 | `_initialize_default_weight`, `save_weights` | | CRUD (single weight) | 654–1056 | `get_weight_*`, `set_default_weight*`, `add_weight`, `delete_weight` | diff --git a/docs/wiki/1_Installation.md b/docs/wiki/1_Installation.md index 4b1157c3..cf39c1cb 100644 --- a/docs/wiki/1_Installation.md +++ b/docs/wiki/1_Installation.md @@ -109,16 +109,21 @@ Useful variants: ```powershell poetry run fetch-weights --check # verify what is installed, download nothing -poetry run fetch-weights --all # also fetch the two generalist models ``` -The release carries six models and installs four by default. The four are the -perspective pair (`seg` + `det`, lateral and top-down), which -`WeightManager.discover_perspective_weights()` finds by filename. The two -behind `--all` -- `best_oi.pt` and `best_seg.pt` -- are 3-class generalists, -carrying a `zup-aqua` class the perspective models lack. They match no -discovery glob, so register them with **Add Weight...** in the model -configuration panel once downloaded. +The release carries six models and installs **all six**. Four are the +perspective pair (`seg` + `det`, lateral and top-down); the other two -- +`best_oi.pt` and `best_seg.pt` -- are 3-class generalists, carrying a +`zup-aqua` class the perspective models lack. `WeightManager.discover_weights()` +registers all of them by filename, so the six appear in the model configuration +panel with no manual step. + +Only the four perspective models are pre-assigned as defaults. A generalist is +something you opt into per role from that panel. + +Before v7.1.0 the generalists sat behind a `--all` flag and matched no discovery +glob, so a standard install carried four models and the other two were invisible +even when downloaded. The flag is still accepted and now changes nothing. ### If `fetch-weights` says there is no module named `zebtrack` diff --git a/install.ps1 b/install.ps1 index 45234fc8..2188f05b 100644 --- a/install.ps1 +++ b/install.ps1 @@ -203,7 +203,7 @@ install.ps1 again. # ---------------------------------------------------------------------- # 4. Detector weights # ---------------------------------------------------------------------- - Write-Step 4 $TotalSteps 'Downloading detector models (~200 MB)' + Write-Step 4 $TotalSteps 'Downloading detector models (~250 MB)' if ($SkipWeights) { Write-Host ' Skipped (-SkipWeights).' diff --git a/pyproject.toml b/pyproject.toml index 72ecc1fa..2aa5a184 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,6 +1,6 @@ [tool.poetry] name = "drerio-logai" -version = "7.0.1" +version = "7.1.0" description = "AI-powered tracking and behavioral analysis for Danio rerio (zebrafish) research. Automates video tracking, behavioral metrics, and scientific report generation." authors = [ "Marco Antonio Sant'Ana Camargos ", diff --git a/setup.sh b/setup.sh index 7fa60860..037b582d 100755 --- a/setup.sh +++ b/setup.sh @@ -96,7 +96,7 @@ if ! poetry run python -c "import zebtrack" >/dev/null 2>&1; then fi # 5. Download detector weights -echo "[4/6] Downloading detector models (~200 MB)..." +echo "[4/6] Downloading detector models (~250 MB)..." if [ "$SKIP_WEIGHTS" -eq 1 ]; then echo "Skipped (--skip-weights)." else diff --git a/src/zebtrack/constants.py b/src/zebtrack/constants.py index 5f0e319d..acedf572 100644 --- a/src/zebtrack/constants.py +++ b/src/zebtrack/constants.py @@ -22,6 +22,12 @@ SPLASH_HEIGHT: Final[int] = 400 SPLASH_CLOSE_DELAY_MS: Final[int] = 300 +# Minimum time a startup step stays on the splash before the next replaces it. +# The benchmark steps were always sent to the splash, but several finish in +# milliseconds, so they were painted and overwritten inside a single frame and +# the sequence was legible only in the log. +SPLASH_MIN_STEP_SECONDS: Final[float] = 0.35 + # --------------------------------------------------------------------------- # Wizard dialog layout # --------------------------------------------------------------------------- diff --git a/src/zebtrack/core/app_runner.py b/src/zebtrack/core/app_runner.py index 6703cd00..06f775ea 100644 --- a/src/zebtrack/core/app_runner.py +++ b/src/zebtrack/core/app_runner.py @@ -7,13 +7,14 @@ import os import sys import threading +import time import warnings from collections.abc import Callable from typing import Any, Literal, cast import structlog -from zebtrack.constants import SPLASH_CLOSE_DELAY_MS +from zebtrack.constants import SPLASH_CLOSE_DELAY_MS, SPLASH_MIN_STEP_SECONDS from zebtrack.core.exceptions import MissingDetectorWeightsError from zebtrack.i18n import _ from zebtrack.logging_config import configure_logging @@ -110,14 +111,49 @@ def run_app( # critically, before _warm_container() builds the whole UI tree below. i18n.install(settings_obj.ui.language) + # THE SPLASH IS BUILT HERE, and everything below it is deliberately + # downstream. It used to be created after the import block further down, + # and that ordering is what made a double-click look like a dead icon: + # measured in logs/analysis.log, roughly four seconds passed with no + # window, no cursor change and no taskbar entry. Under the desktop + # shortcut (pythonw.exe, no console) there is not even a terminal to + # suggest something is happening. + # + # Two separate costs sat in front of it, both invisible as plain + # `import` lines: + # * `from zebtrack.utils import set_seed` -- zebtrack.utils imports + # torch at module level, about 1.1 s. + # * the di_registrations block below -- it eagerly builds the whole + # coordinator/service graph, pulling ultralytics, cv2 and + # matplotlib on top of torch, about 3 s. + # + # What this block needs is cheap by comparison: splash_screen imports + # tkinter, PIL and constants, and icon_utils/window_utils are thin + # wrappers. Keep it that way -- an expensive import added above or + # inside here silently moves the dead gap back. + from zebtrack.ui.icon_utils import set_window_icon + from zebtrack.ui.splash_screen import create_splash + from zebtrack.ui.window_utils import maximize_window + + set_window_icon(root) + splash = create_splash(parent=root) + splash.update_progress(0.0, _("Starting...")) + + log.info("application.starting", component="main") + _set_windows_app_id(log) + + splash.update_progress(0.03, _("Loading the tracking engine...")) + from zebtrack.utils import set_seed if settings_obj.reproducibility and settings_obj.reproducibility.seed: set_seed(settings_obj.reproducibility.seed) log.info("reproducibility.seed.set", seed=settings_obj.reproducibility.seed) - log.info("application.starting", component="main") - _set_windows_app_id(log) + # Needs hardware_benchmark, hence after the torch import above rather + # than before it. Puts the first-run hint on screen while the heavier + # import block below runs. + _detect_first_launch(settings_obj, splash) from zebtrack.core.dependency_container import LazyRef from zebtrack.core.di_registrations import ( @@ -130,20 +166,12 @@ def run_app( from zebtrack.io.recorder_factory import RecorderFactory from zebtrack.settings import save_settings from zebtrack.ui.event_bus_v2 import EventBusV2 - from zebtrack.ui.icon_utils import set_window_icon - from zebtrack.ui.splash_screen import create_splash - from zebtrack.ui.window_utils import maximize_window - - set_window_icon(root) - splash = create_splash(parent=root) - splash.update_progress(0.0, _("Loading settings...")) - - # Detect first launch (no cached benchmark) and inform user - _detect_first_launch(settings_obj, splash) # Before the benchmark, not after: without weights the run is doomed, - # and the benchmark is the slowest step in startup. Failing here turns a - # long wait ending in a generic error into an immediate, specific one. + # and the benchmark now converts a model, which is the slowest step in + # startup. Failing here turns a long wait ending in a generic error into + # an immediate, specific one -- and avoids converting a weight for an + # install that cannot track anyway. _require_detector_weights(settings_obj, log) _run_benchmark_if_enabled(settings_obj, splash, save_settings, log) @@ -179,6 +207,11 @@ def close_splash_and_show_main() -> None: splash.destroy() maximize_window(root) root.deiconify() + # After deiconify, never before: the window is modal and centres on + # its parent, and a transient of a withdrawn master is itself + # withdrawn -- the same trap that made the first-run language + # chooser hang on a window nobody could see. + _show_welcome_if_due(container, log) root.after(SPLASH_CLOSE_DELAY_MS, close_splash_and_show_main) controller.run() @@ -449,17 +482,33 @@ def _run_benchmark_if_enabled( cached = load_cached_benchmark() if cached is None: - splash.update_progress(0.02, _("Optimizing for your hardware (first run)...")) + splash.update_progress(0.05, _("Optimizing for your hardware (first run)...")) log.info("benchmark.running_first_time") + # Each step is held on screen for a beat before the next one + # replaces it. Without this the fast steps are unreadable: the + # messages were always sent to the splash, but a step that finishes + # in milliseconds is painted and overwritten within one frame, so + # the sequence only ever existed in the log. The wait is skipped + # whenever the step already took longer than the floor, which is + # the normal case for the conversion and pipeline steps. + last_shown = time.monotonic() + def progress_cb(step: int, total: int, message: str) -> None: - # Benchmark occupies progress range 0.02-0.15 - frac = 0.02 + (step / max(total, 1)) * 0.13 + nonlocal last_shown + elapsed = time.monotonic() - last_shown + remaining = SPLASH_MIN_STEP_SECONDS - elapsed + if remaining > 0: + splash.sleep(remaining) + # Benchmark occupies progress range 0.05-0.20 + frac = 0.05 + (step / max(total, 1)) * 0.15 splash.update_progress(frac, message) + last_shown = time.monotonic() benchmark_result = get_or_run_benchmark( quick_mode=True, progress_callback=progress_cb, + settings_obj=settings_obj, ) if benchmark_result.recommendation: @@ -525,6 +574,25 @@ def progress_cb(step: int, total: int, message: str) -> None: log.warning("benchmark.failed", error=str(e)) +def _show_welcome_if_due(container: Any, log: Any) -> None: + """Offer the getting-started window once the main window is up. + + Lives here rather than inside the GUI's own startup because this is the + function that owns the splash-to-main-window transition, and "after the + main window is visible" is the whole constraint. + + Any failure is swallowed: an informational window must never be the reason + an otherwise working application does not appear. + """ + try: + from zebtrack.ui.gui import ApplicationGUI + + gui = container.resolve(ApplicationGUI) + gui.show_welcome_dialog() + except Exception: + log.warning("welcome.startup_show_failed", exc_info=True) + + def _warm_container(container: Any, splash: Any) -> None: from zebtrack.core.project.project_manager import ProjectManager from zebtrack.core.project.project_workflow_service import ProjectWorkflowService diff --git a/src/zebtrack/core/services/weight_manager.py b/src/zebtrack/core/services/weight_manager.py index e0abdbd5..d6f9ca29 100644 --- a/src/zebtrack/core/services/weight_manager.py +++ b/src/zebtrack/core/services/weight_manager.py @@ -244,8 +244,8 @@ def _load_weights(self) -> None: log.info("weights.migration.completed") log.info("weights.config.loaded", path=self.config_path) - # Auto-discover any new perspective weight files - self.discover_perspective_weights() + # Auto-discover any new weight files + self.discover_weights() except (OSError, json.JSONDecodeError) as e: log.error("weights.config.load_error", error=str(e)) self.weights = {} @@ -481,13 +481,26 @@ def _classify_perspective(self, filename: str) -> str | None: return "top_down" return None - def discover_perspective_weights(self) -> int: - """Auto-discover perspective weight files in the configured weights folder. + def discover_weights(self) -> int: + """Auto-discover weight files in the configured weights folder. - Scans for ``best_*_lateral.pt`` and ``best_*_topdown.pt`` files that - are not already registered and adds them to the catalog. Falls back - to the legacy project-root location when the weights folder is empty, - so migrated installs don't lose their pre-existing files. + Scans for every ``best_*.pt`` that is not already registered and adds + it to the catalog. Falls back to the legacy project-root location when + the weights folder is empty, so migrated installs don't lose their + pre-existing files. + + **The glob used to be ``best_*_lateral.pt`` / ``best_*_topdown.pt`` + only**, which quietly excluded the two flat-named generalists that ship + alongside the four specialists (``best_oi.pt``, ``best_seg.pt``). They + landed in ``weights/`` and never appeared in the catalog, so nothing in + the UI could select them -- the reason the installer did not bother + downloading them either. + + A discovered generalist is registered but **never claims a default + slot**: ``is_default_*`` stays False for a weight with no perspective. + The specialists are trained for a specific camera angle and must stay + the defaults; a generalist is something the researcher opts into from + the model configuration panel. Returns: Number of newly discovered weights. @@ -503,40 +516,41 @@ def discover_perspective_weights(self) -> int: for scan_dir in scan_dirs: if not scan_dir.exists(): continue - for pattern in ("best_*_lateral.pt", "best_*_topdown.pt"): - for pt_file in scan_dir.glob(pattern): - weight_name = pt_file.name - if weight_name in self.weights: - continue - weight_type = self._classify_weight_type(weight_name) or "seg" - perspective = self._classify_perspective(weight_name) - target = _default_target_for_type(weight_type) - self.weights[weight_name] = { - "path": str(pt_file.absolute()), - "is_default": True, - "type": weight_type, - "target": target, - "perspective": perspective, - "is_default_seg": weight_type == "seg", - "is_default_det": weight_type == "det", - "is_default_seg_aquarium": False, - "is_default_seg_zebrafish": False, - "is_default_det_aquarium": False, - "is_default_det_zebrafish": False, - "openvino_path": "", - "openvino_hash": "", - "openvino_status": OPENVINO_STATUS_NOT_CONVERTED, - "last_conversion_error": None, - } - discovered += 1 - log.info( - "weights.auto_discover.found", - name=weight_name, - type=weight_type, - target=target, - perspective=perspective, - location=str(scan_dir), - ) + for pt_file in sorted(scan_dir.glob("best_*.pt")): + weight_name = pt_file.name + if weight_name in self.weights: + continue + weight_type = self._classify_weight_type(weight_name) or "seg" + perspective = self._classify_perspective(weight_name) + target = _default_target_for_type(weight_type) + # Only a perspective-tagged specialist may be a default. + is_specialist = perspective is not None + self.weights[weight_name] = { + "path": str(pt_file.absolute()), + "is_default": is_specialist, + "type": weight_type, + "target": target, + "perspective": perspective, + "is_default_seg": is_specialist and weight_type == "seg", + "is_default_det": is_specialist and weight_type == "det", + "is_default_seg_aquarium": False, + "is_default_seg_zebrafish": False, + "is_default_det_aquarium": False, + "is_default_det_zebrafish": False, + "openvino_path": "", + "openvino_hash": "", + "openvino_status": OPENVINO_STATUS_NOT_CONVERTED, + "last_conversion_error": None, + } + discovered += 1 + log.info( + "weights.auto_discover.found", + name=weight_name, + type=weight_type, + target=target, + perspective=perspective, + location=str(scan_dir), + ) if discovered: self.save_weights() return discovered @@ -689,7 +703,7 @@ def _coerce_path(candidate: Any, *, source: str) -> str | None: # Always sweep the weights folder so freshly-dropped .pt files are # registered even when the settings filenames are blank. - self.discover_perspective_weights() + self.discover_weights() def save_weights(self) -> None: """Save the current weights configuration to the JSON file.""" @@ -1332,11 +1346,11 @@ def clear_openvino_cache(self, name: str | None = None) -> dict[str, Any]: def rescan_source_folder(self) -> int: """Re-run discovery against the configured weights folder. - Convenience wrapper around :meth:`discover_perspective_weights` that + Convenience wrapper around :meth:`discover_weights` that simply reports how many new files were registered. Existing entries are left untouched (use :meth:`reset_registry` for a clean slate). """ - added = self.discover_perspective_weights() + added = self.discover_weights() log.info("weights.rescan.completed", added=added, weights_dir=self.weights_dir) return added @@ -1360,7 +1374,7 @@ def reset_registry(self) -> int: ) # Re-seed from settings and rescan the weights folder. self._initialize_default_weight() - self.discover_perspective_weights() + self.discover_weights() return len(self.weights) def validate_weight_files(self) -> dict[str, bool]: diff --git a/src/zebtrack/fetch_weights.py b/src/zebtrack/fetch_weights.py index 68f323f8..7d7ef1da 100644 --- a/src/zebtrack/fetch_weights.py +++ b/src/zebtrack/fetch_weights.py @@ -268,12 +268,15 @@ def generate_manifest( "bytes": path.stat().st_size, "type": weight_type, "perspective": perspective, - # `required` tracks DISCOVERY, not quality: only the - # perspective-suffixed names are matched by - # WeightManager.discover_perspective_weights(). The flat-named - # generalists have to be registered by hand, so they are not - # part of the default download. - "required": perspective is not None, + # Everything the manifest lists is fetched by default. This + # used to be `perspective is not None`, which excluded the two + # flat-named generalists (best_oi.pt, best_seg.pt) because + # WeightManager could not discover them -- so a standard install + # left four of the six models on the server. The discovery gap + # was the real defect and is fixed in + # WeightManager.discover_weights(); the download no longer has + # to model it. + "required": True, } ) @@ -325,7 +328,10 @@ def main(argv: list[str] | None = None) -> int: parser.add_argument( "--all", action="store_true", - help="Also fetch the generalist models the manifest marks optional.", + help=( + "Accepted for compatibility. Every model in the manifest is fetched " + "by default, so this flag no longer changes the selection." + ), ) parser.add_argument( "--dest", diff --git a/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.mo b/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.mo index 9e56830b..a4cedcc3 100644 Binary files a/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.mo and b/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.mo differ diff --git a/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.po b/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.po index 4af07d69..066c9e9d 100644 --- a/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.po +++ b/src/zebtrack/locales/pt_BR/LC_MESSAGES/zebtrack.po @@ -15606,3 +15606,87 @@ msgstr "" "As zonas salvas na pasta de \"{name}\" foram carregadas.\n" "\n" "Confira se os contornos correspondem a este vídeo antes de iniciar a análise: uma arena de outra gravação ainda produz um relatório completo, medido contra a região errada." + +msgid "Loading the tracking engine..." +msgstr "Carregando o motor de rastreamento..." + +msgid "Step 1/7 — Detecting hardware..." +msgstr "Etapa 1/7 — Detectando o hardware..." + +msgid "Step 2/7 — Preparing the model for your hardware (one time, may take a minute)..." +msgstr "Etapa 2/7 — Preparando o modelo para o seu hardware (uma única vez, pode levar um minuto)..." + +msgid "Step 3/7 — Testing video decoding..." +msgstr "Etapa 3/7 — Testando a decodificação de vídeo..." + +msgid "Step 4/7 — Testing inference performance..." +msgstr "Etapa 4/7 — Testando o desempenho de inferência..." + +msgid "Step 5/7 — Testing live camera pipeline..." +msgstr "Etapa 5/7 — Testando o pipeline da câmera ao vivo..." + +msgid "Step 6/7 — Testing batch processing..." +msgstr "Etapa 6/7 — Testando o processamento em lote..." + +msgid "Step 7/7 — Generating recommendations..." +msgstr "Etapa 7/7 — Gerando recomendações..." + +msgid "Model settings..." +msgstr "Definições de modelo..." + +msgid "Getting Started..." +msgstr "Primeiros passos..." + +msgid "Getting started" +msgstr "Primeiros passos" + +msgid "One thing is worth setting up before your first analysis." +msgstr "Vale ajustar uma coisa antes da sua primeira análise." + +msgid "The detector models" +msgstr "Os modelos do detector" + +msgid "" +"Six models are installed. Four are specialists, trained for one camera angle each: segmentation and detection, for a lateral view and for a top-down view. The other two are generalists that work from any angle but with less precision.\n" +"\n" +"You assign a model to each of four roles — detecting the aquarium and detecting the animal, by segmentation and by bounding box. The specialists are pre-assigned; pick the pair that matches how your camera is mounted." +msgstr "" +"Seis modelos estão instalados. Quatro são especialistas, treinados cada um para um ângulo de câmera: segmentação e detecção, para vista lateral e para vista superior. Os outros dois são generalistas, que funcionam de qualquer ângulo, porém com menos precisão.\n" +"\n" +"Você atribui um modelo a cada um de quatro papéis — detectar o aquário e detectar o animal, por segmentação e por caixa delimitadora. Os especialistas já vêm atribuídos; escolha o par que corresponde à montagem da sua câmera." + +msgid "Acceleration" +msgstr "Aceleração" + +msgid "OpenVINO speeds up Intel processors, integrated graphics and NPUs. On a machine with an NVIDIA card, PyTorch with CUDA is usually faster and OpenVINO is not worth enabling." +msgstr "O OpenVINO acelera processadores Intel, gráficos integrados e NPUs. Numa máquina com placa NVIDIA, o PyTorch com CUDA costuma ser mais rápido e não compensa ligar o OpenVINO." + +msgid "An NVIDIA GPU was detected" +msgstr "Uma GPU NVIDIA foi detectada" + +msgid "PyTorch with CUDA will normally be the fastest option here, so you can leave OpenVINO off. It exists for machines without an NVIDIA card, where it moves the work onto the Intel CPU, integrated graphics or NPU instead." +msgstr "O PyTorch com CUDA costuma ser a opção mais rápida aqui, então você pode deixar o OpenVINO desligado. Ele existe para máquinas sem placa NVIDIA, onde passa o trabalho para a CPU Intel, os gráficos integrados ou a NPU." + +msgid "No NVIDIA GPU — OpenVINO is the fast path here" +msgstr "Sem GPU NVIDIA — o OpenVINO é o caminho rápido aqui" + +msgid "Without a CUDA card, OpenVINO is what makes tracking fast on this machine. It can use: {devices}. Enable it in the model settings and convert the models you plan to use; conversion happens once and is cached." +msgstr "Sem uma placa CUDA, o OpenVINO é o que torna o rastreamento rápido nesta máquina. Ele pode usar: {devices}. Ligue-o nas definições de modelo e converta os modelos que pretende usar; a conversão acontece uma única vez e fica em cache." + +msgid "No hardware acceleration was detected" +msgstr "Nenhuma aceleração de hardware foi detectada" + +msgid "Tracking will run on the CPU through PyTorch. It works, but expect it to be slower than on a machine with an NVIDIA card or Intel acceleration." +msgstr "O rastreamento vai rodar na CPU via PyTorch. Funciona, mas espere que seja mais lento do que numa máquina com placa NVIDIA ou aceleração Intel." + +msgid "All of this lives in Settings > Model settings, at any time." +msgstr "Tudo isso fica em Configurações > Definições de modelo, a qualquer momento." + +msgid "Do not show this again" +msgstr "Não mostrar novamente" + +msgid "Not now" +msgstr "Agora não" + +msgid "Open model settings" +msgstr "Abrir definições de modelo" diff --git a/src/zebtrack/settings.py b/src/zebtrack/settings.py index ca29815e..03272dd5 100644 --- a/src/zebtrack/settings.py +++ b/src/zebtrack/settings.py @@ -1024,6 +1024,17 @@ class UISettings(BaseModel): "allowed values in sync with zebtrack.i18n.SUPPORTED_LANGUAGES." ), ) + show_welcome: bool = Field( + True, + description=( + "Show the getting-started window after the main window opens. It " + "explains the six detector models, the four roles they fill, and " + "when OpenVINO is worth enabling. Cleared by the 'do not show " + "again' checkbox, which writes only this key via " + "write_local_override(); the window stays reachable from " + "Help > Getting Started." + ), + ) class PerformanceSettings(BaseModel): diff --git a/src/zebtrack/ui/components/menu_manager.py b/src/zebtrack/ui/components/menu_manager.py index 9ed4e89c..3e3bbe67 100644 --- a/src/zebtrack/ui/components/menu_manager.py +++ b/src/zebtrack/ui/components/menu_manager.py @@ -68,6 +68,13 @@ def create_menu_bar(self): settings_menu = Menu(menubar, tearoff=0) menubar.add_cascade(label=_("Settings"), menu=settings_menu) settings_menu.add_command(label=_("Language..."), command=self.change_language) + # The model configuration panel had no menu entry at all: it was + # reachable only from a button inside a project view, so a new operator + # could not find the one screen they need before their first analysis. + settings_menu.add_command( + label=_("Model settings..."), + command=self.gui._open_global_model_configuration_window, + ) # Tools menu tools_menu = Menu(menubar, tearoff=0) @@ -80,8 +87,17 @@ def create_menu_bar(self): # Help menu help_menu = Menu(menubar, tearoff=0) menubar.add_cascade(label=_("Help"), menu=help_menu) + help_menu.add_command(label=_("Getting Started..."), command=self.show_welcome_dialog) help_menu.add_command(label=_("About DRerio LogAI"), command=self.show_about_dialog) + def show_welcome_dialog(self) -> None: + """Reopen the getting-started window on demand. + + Separate from the automatic first-run showing so that dismissing it + permanently does not make the explanation unreachable. + """ + self.gui.show_welcome_dialog(force=True) + def change_language(self) -> None: """Let the user pick a different interface language. diff --git a/src/zebtrack/ui/dialogs/welcome_dialog.py b/src/zebtrack/ui/dialogs/welcome_dialog.py new file mode 100644 index 00000000..f9276a4d --- /dev/null +++ b/src/zebtrack/ui/dialogs/welcome_dialog.py @@ -0,0 +1,261 @@ +"""Getting-started window shown once the main window is up. + +The first thing a new operator saw was a full application with no indication of +what to do first -- and, in particular, no hint that the six detector models +need assigning to roles before tracking behaves sensibly. Nothing in the menu +bar led to the model configuration panel either; it was reachable only from a +button inside a project view. + +This window closes that gap. It explains the models, the roles they fill and +when OpenVINO is worth enabling, then offers to open the panel where those +choices are made. + +**The OpenVINO advice is read off the machine, not written in the abstract.** +"Use OpenVINO when you have no NVIDIA card" is true but useless to someone who +does not know what they have; :func:`zebtrack.utils.hardware_detection.get_hardware_summary` +already answers that question, and the bootstrapper has run it by the time this +window opens. +""" + +from __future__ import annotations + +import tkinter as tk +from tkinter import ttk +from typing import Any + +import structlog + +from zebtrack.i18n import _ + +log = structlog.get_logger() + +_WRAP_PX = 560 + + +def _hardware_advice() -> tuple[str, str]: + """Describe this machine's accelerator and what it implies for OpenVINO. + + Returns: + ``(headline, detail)``. Both are already translated. On any detection + failure the pair degrades to generic guidance rather than raising: a + getting-started window is not worth blocking startup over. + """ + try: + from zebtrack.utils.hardware_detection import get_hardware_summary + + summary = get_hardware_summary() + except Exception: + log.debug("welcome.hardware_summary.suppressed", exc_info=True) + return ( + _("Acceleration"), + _( + "OpenVINO speeds up Intel processors, integrated graphics and NPUs. " + "On a machine with an NVIDIA card, PyTorch with CUDA is usually " + "faster and OpenVINO is not worth enabling." + ), + ) + + if summary.get("cuda_available"): + return ( + _("An NVIDIA GPU was detected"), + _( + "PyTorch with CUDA will normally be the fastest option here, so " + "you can leave OpenVINO off. It exists for machines without an " + "NVIDIA card, where it moves the work onto the Intel CPU, " + "integrated graphics or NPU instead." + ), + ) + + devices = summary.get("openvino_devices") or [] + if summary.get("openvino_available"): + named = ", ".join(str(device) for device in devices) or _("CPU") + return ( + _("No NVIDIA GPU — OpenVINO is the fast path here"), + _( + "Without a CUDA card, OpenVINO is what makes tracking fast on this " + "machine. It can use: {devices}. Enable it in the model settings " + "and convert the models you plan to use; conversion happens once " + "and is cached." + ).format(devices=named), + ) + + return ( + _("No hardware acceleration was detected"), + _( + "Tracking will run on the CPU through PyTorch. It works, but expect it " + "to be slower than on a machine with an NVIDIA card or Intel " + "acceleration." + ), + ) + + +class WelcomeDialog: + """Modal getting-started window. + + Built as a plain ``Toplevel`` in the style of + :mod:`zebtrack.ui.language_dialog` rather than through ``DialogManager``: + it has one job, no validation and no project state, and it must survive + being opened before any project exists. + """ + + def __init__(self, parent: Any, *, on_open_model_settings: Any = None) -> None: + """Build and show the window. + + Args: + parent: The main window. It is already mapped when this runs, so + ``transient`` is safe -- unlike the first-launch language + chooser, whose master is still withdrawn. + on_open_model_settings: Called when the user chooses to open the + model configuration panel. Invoked after this window closes, + so the panel is not fighting a grab. + """ + self._on_open_model_settings = on_open_model_settings + self.opened_model_settings = False + + self.window = tk.Toplevel(parent) + self.window.title(_("Getting started")) + self.window.transient(parent) + self.window.resizable(False, False) + + self.dont_show_again = tk.BooleanVar(master=self.window, value=False) + + frame = ttk.Frame(self.window, padding=20) + frame.pack(fill="both", expand=True) + + self._build_body(frame) + self._build_buttons(frame) + + self.window.protocol("WM_DELETE_WINDOW", self._close) + self.window.bind("", lambda _event: self._close()) + + self._center_on(parent) + self.window.grab_set() + self.window.focus_force() + log.info("welcome.shown") + + def _build_body(self, frame: ttk.Frame) -> None: + ttk.Label( + frame, + text=_("Welcome to DRerio LogAI"), + font=("Segoe UI", 14, "bold"), + ).pack(anchor="w") + + ttk.Label( + frame, + text=_("One thing is worth setting up before your first analysis."), + wraplength=_WRAP_PX, + justify="left", + ).pack(anchor="w", pady=(4, 14)) + + models = ttk.LabelFrame(frame, text=_("The detector models"), padding=10) + models.pack(fill="x", pady=(0, 10)) + ttk.Label( + models, + text=_( + "Six models are installed. Four are specialists, trained for one " + "camera angle each: segmentation and detection, for a lateral view " + "and for a top-down view. The other two are generalists that work " + "from any angle but with less precision.\n\n" + "You assign a model to each of four roles — detecting the aquarium " + "and detecting the animal, by segmentation and by bounding box. " + "The specialists are pre-assigned; pick the pair that matches how " + "your camera is mounted." + ), + wraplength=_WRAP_PX, + justify="left", + ).pack(anchor="w") + + headline, detail = _hardware_advice() + hardware = ttk.LabelFrame(frame, text=_("OpenVINO"), padding=10) + hardware.pack(fill="x", pady=(0, 10)) + ttk.Label(hardware, text=headline, font=("Segoe UI", 9, "bold")).pack(anchor="w") + ttk.Label(hardware, text=detail, wraplength=_WRAP_PX, justify="left").pack( + anchor="w", pady=(2, 0) + ) + + ttk.Label( + frame, + text=_("All of this lives in Settings > Model settings, at any time."), + wraplength=_WRAP_PX, + justify="left", + foreground="#666666", + ).pack(anchor="w", pady=(0, 10)) + + def _build_buttons(self, frame: ttk.Frame) -> None: + ttk.Checkbutton( + frame, + text=_("Do not show this again"), + variable=self.dont_show_again, + ).pack(anchor="w", pady=(0, 10)) + + row = ttk.Frame(frame) + row.pack(fill="x") + ttk.Button(row, text=_("Not now"), command=self._close).pack(side="right") + ttk.Button( + row, + text=_("Open model settings"), + command=self._open_model_settings, + ).pack(side="right", padx=(0, 8)) + + def _open_model_settings(self) -> None: + self.opened_model_settings = True + self._close() + if self._on_open_model_settings is not None: + self._on_open_model_settings() + + def _close(self) -> None: + if self.dont_show_again.get(): + _persist_dismissal() + try: + self.window.grab_release() + except tk.TclError: + log.debug("welcome.grab_release.suppressed", exc_info=True) + self.window.destroy() + + def _center_on(self, parent: Any) -> None: + """Centre over the main window, falling back to the screen.""" + self.window.update_idletasks() + width = self.window.winfo_width() + height = self.window.winfo_height() + try: + x = parent.winfo_rootx() + (parent.winfo_width() - width) // 2 + y = parent.winfo_rooty() + (parent.winfo_height() - height) // 2 + except tk.TclError: + log.debug("welcome.center_on_parent.suppressed", exc_info=True) + x = (self.window.winfo_screenwidth() - width) // 2 + y = (self.window.winfo_screenheight() - height) // 2 + self.window.geometry(f"+{max(x, 0)}+{max(y, 0)}") + + +def _persist_dismissal() -> None: + """Record the preference, writing only ``ui.show_welcome``. + + Uses ``write_local_override`` rather than ``save_settings``: the latter + dumps the whole resolved Settings tree into ``config.local.yaml``, freezing + every current default and permanently defeating the layered + ``config.yaml`` -> ``config.local.yaml`` merge for that installation. + + A write failure degrades to showing the window again next launch, which is + a minor annoyance; raising here would take down the main window instead. + """ + try: + from zebtrack.settings import write_local_override + + write_local_override({"ui": {"show_welcome": False}}) + log.info("welcome.dismissed_permanently") + except Exception: + log.warning("welcome.dismiss_persist_failed", exc_info=True) + + +def should_show_welcome(settings_obj: Any) -> bool: + """Whether the getting-started window is due. + + Type-checked rather than trusted: a ``MagicMock`` settings object answers + every attribute truthily, and this decides whether a modal window appears + over the main window. + """ + ui = getattr(settings_obj, "ui", None) + if ui is None: + return False + value = getattr(ui, "show_welcome", None) + return value is True diff --git a/src/zebtrack/ui/gui.py b/src/zebtrack/ui/gui.py index 81886d05..ec2ad208 100644 --- a/src/zebtrack/ui/gui.py +++ b/src/zebtrack/ui/gui.py @@ -682,6 +682,38 @@ def _open_global_model_configuration_window(self): with self.controller.global_calibration_session(): CalibrationDialog(self.root, self.controller, show_diagnostics=False) + def show_welcome_dialog(self, *, force: bool = False) -> bool: + """Show the getting-started window if it is due. + + Called from ``app_runner`` once the main window is mapped -- the window + is modal and centres on its parent, so it must not be built while the + root is still withdrawn. + + Args: + force: Show it regardless of the stored preference. Used by + Help > Getting Started, so dismissing it permanently does not + make the explanation unreachable. + + Returns: + True when the window was shown. + """ + from zebtrack.ui.dialogs.welcome_dialog import WelcomeDialog, should_show_welcome + + if not force and not should_show_welcome(self.settings): + return False + + try: + WelcomeDialog( + self.root, + on_open_model_settings=self._open_global_model_configuration_window, + ) + except Exception: + # Informational only. A failure here must not stop the operator from + # reaching an application that is otherwise fully up. + log.warning("welcome.show_failed", exc_info=True) + return False + return True + def _open_global_model_diagnostics_window(self): from zebtrack.ui.dialogs.model_diagnostics_dialog import ModelDiagnosticsDialog diff --git a/src/zebtrack/ui/splash_screen.py b/src/zebtrack/ui/splash_screen.py index fe4bb433..6f379dac 100644 --- a/src/zebtrack/ui/splash_screen.py +++ b/src/zebtrack/ui/splash_screen.py @@ -1,6 +1,7 @@ """Splash screen for DRerio LogAI startup.""" import platform +import time import tkinter as tk from pathlib import Path from tkinter import ttk @@ -22,6 +23,9 @@ TEXT_SECONDARY = "#a0a0a0" # Gray for secondary text TEXT_MUTED = "#505050" # Darker gray for footer text +# Granularity of SplashScreen.sleep's event-loop pumping. +_SLEEP_SLICE_SECONDS = 0.02 + class SplashScreen: """Professional splash screen with logo and loading indicator. @@ -208,6 +212,24 @@ def update_progress(self, fraction: float, message: str | None = None) -> None: self.splash.update() log.debug("splash.progress.updated", pct=int(pct), message=message) + def sleep(self, seconds: float) -> None: + """Hold the current frame on screen for *seconds*, still repainting. + + Not ``time.sleep``: this runs before ``mainloop`` starts, so blocking + outright leaves the window unpainted and Windows draws it as a white + rectangle -- worse than the flicker it is meant to fix. Pumping the + event loop in short slices keeps the splash alive. + + Args: + seconds: How long to wait. Zero or negative returns immediately. + """ + if seconds <= 0: + return + deadline = time.monotonic() + seconds + while time.monotonic() < deadline: + self.splash.update() + time.sleep(_SLEEP_SLICE_SECONDS) + def set_first_launch(self, is_first: bool) -> None: """Show or hide the first-launch hint. diff --git a/src/zebtrack/utils/hardware_benchmark.py b/src/zebtrack/utils/hardware_benchmark.py index be588a25..48242153 100644 --- a/src/zebtrack/utils/hardware_benchmark.py +++ b/src/zebtrack/utils/hardware_benchmark.py @@ -32,6 +32,13 @@ import structlog from zebtrack.i18n import _ +from zebtrack.paths import repo_root + +# Must match WeightManager's OPENVINO_CACHE_DIR and the folder --reset removes. +OPENVINO_CACHE_DIRNAME = "openvino_model_cache" + +# Keep in sync with the "Step N/7" strings in run_adaptive_benchmark. +TOTAL_BENCHMARK_STEPS = 7 log = structlog.get_logger() @@ -162,6 +169,10 @@ class SystemBenchmarkResult: # Recommendations recommendation: BenchmarkRecommendation | None = None + # True when nothing could actually be measured (no OpenVINO model, no CUDA). + # Such a result is reported but never cached -- see save_benchmark_cache. + inconclusive: bool = False + def to_dict(self) -> dict: result = { "benchmark_version": self.benchmark_version, @@ -173,6 +184,7 @@ def to_dict(self) -> dict: "pipeline_live_results": self.pipeline_live_results, "pipeline_batch_results": self.pipeline_batch_results, "recommendation": self.recommendation.to_dict() if self.recommendation else None, + "inconclusive": self.inconclusive, } return result @@ -187,6 +199,7 @@ def from_dict(cls, data: dict) -> SystemBenchmarkResult: result.compute_results = data.get("compute_results", {}) result.pipeline_live_results = data.get("pipeline_live_results", {}) result.pipeline_batch_results = data.get("pipeline_batch_results", {}) + result.inconclusive = bool(data.get("inconclusive", False)) if data.get("recommendation"): result.recommendation = BenchmarkRecommendation.from_dict(data["recommendation"]) return result @@ -338,12 +351,19 @@ def detect_hardware_profile() -> HardwareProfile: def _find_test_video() -> Path | None: - """Find a test video for benchmarking.""" + """Find a test video for benchmarking. + + Anchored at the repository root, not the working directory. Launched from + the desktop shortcut these agreed by luck -- the .lnk pins + WorkingDirectory to the repo -- but from anywhere else the benchmark + searched folders that did not exist and silently measured nothing. + """ + root = repo_root() search_paths = [ - Path("live_analysis_sessions"), - Path("tests/fixtures"), - Path("test_data"), - Path("resources"), + root / "live_analysis_sessions", + root / "tests" / "fixtures", + root / "test_data", + root / "resources", ] for base in search_paths: @@ -357,8 +377,13 @@ def _find_test_video() -> Path | None: def _find_openvino_model() -> Path | None: - """Find an OpenVINO model for benchmarking.""" - cache_dir = Path("openvino_model_cache") + """Find a converted OpenVINO model to benchmark against. + + Returns None when nothing has been converted yet -- the normal state of a + fresh install, and the reason :func:`run_adaptive_benchmark` now converts + one before measuring rather than skipping every measurement step. + """ + cache_dir = repo_root() / OPENVINO_CACHE_DIRNAME if not cache_dir.exists(): return None @@ -370,6 +395,58 @@ def _find_openvino_model() -> Path | None: return None +def _prepare_openvino_model(settings_obj: Any | None) -> Path | None: + """Convert one weight to OpenVINO so the inference steps have a subject. + + Without this the first run measured **nothing**. Steps 3-5 are all guarded + by ``if video_path and model_path and profile.openvino_available``, and on a + fresh install ``openvino_model_cache/`` does not exist yet, so + :func:`_find_openvino_model` returned None and every measurement was + skipped. The whole benchmark finished in 0.2 s and produced a + recommendation of CPU at 0.0 FPS -- which was then cached and never + recomputed, so the machine was configured off a measurement that never + happened. + + The conversion is not overhead bolted on to fix that: it is work the + application pays anyway the first time OpenVINO is used for real. Doing it + here moves it out of the first analysis and into a moment where the splash + can explain the wait. + + Returns: + Path to an OpenVINO ``.xml``, or None when there is nothing to convert + or the conversion failed. A failure degrades to a CPU-only benchmark; + it must never stop the application from starting. + """ + if settings_obj is None: + return None + + try: + # Imported lazily: this module is reachable from startup paths that + # must not drag the service layer in when no conversion is needed. + from zebtrack.core.services.weight_manager import WeightManager + + manager = WeightManager(settings_obj=settings_obj) + name, _details = manager.get_default_det_weight() + if not name: + name, _details = manager.get_default_weight() + if not name: + log.info("benchmark.prepare_model.no_weight_registered") + return None + + log.info("benchmark.prepare_model.converting", weight=name) + converted = manager.convert_to_openvino(name) + if not converted: + log.warning("benchmark.prepare_model.conversion_returned_nothing", weight=name) + return None + except Exception as exc: + # Broad on purpose: ultralytics' exporter raises a wide and unstable + # set of errors, and none of them justify refusing to start. + log.warning("benchmark.prepare_model.failed", error=str(exc)) + return None + + return _find_openvino_model() + + def _benchmark_video_decode(video_path: Path, num_frames: int = 50) -> dict[str, BenchmarkResult]: """Benchmark video decoding with different backends.""" results: dict[str, BenchmarkResult] = {} @@ -763,6 +840,7 @@ def _get_benchmark_devices(profile: HardwareProfile) -> list[str]: def run_adaptive_benchmark( quick_mode: bool = False, progress_callback: Callable[[int, int, str], None] | None = None, + settings_obj: Any | None = None, ) -> SystemBenchmarkResult: """ Run adaptive benchmark based on detected hardware. @@ -770,6 +848,9 @@ def run_adaptive_benchmark( Args: quick_mode: If True, run fewer iterations for faster results. progress_callback: Optional callback(step: int, total: int, message: str) + settings_obj: Settings used to locate the weights when no OpenVINO model + has been converted yet. Without it the inference steps have nothing + to measure and the result is reported as inconclusive. Returns: SystemBenchmarkResult with all benchmark data and recommendations. @@ -790,7 +871,7 @@ def report_progress(step: int, total: int, message: str): progress_callback(step, total, message) # Step 1: Detect hardware - report_progress(1, 6, _("Step 1/6 — Detecting hardware...")) + report_progress(1, TOTAL_BENCHMARK_STEPS, _("Step 1/7 — Detecting hardware...")) profile = detect_hardware_profile() result.hardware = profile @@ -798,6 +879,15 @@ def report_progress(step: int, total: int, message: str): video_path = _find_test_video() model_path = _find_openvino_model() + # Step 2: make sure the measurement steps have a subject + report_progress( + 2, + TOTAL_BENCHMARK_STEPS, + _("Step 2/7 — Preparing the model for your hardware (one time, may take a minute)..."), + ) + if model_path is None and profile.openvino_available: + model_path = _prepare_openvino_model(settings_obj) + sample_frame = None if video_path: cap = cv2.VideoCapture(str(video_path)) @@ -807,15 +897,15 @@ def report_progress(step: int, total: int, message: str): if sample_frame is None: sample_frame = np.random.randint(0, 255, (720, 1280, 3), dtype=np.uint8) - # Step 2: Video decode benchmark - report_progress(2, 6, _("Step 2/6 — Testing video decoding...")) + # Step 3: Video decode benchmark + report_progress(3, TOTAL_BENCHMARK_STEPS, _("Step 3/7 — Testing video decoding...")) decode_results = {} if video_path: decode_results = _benchmark_video_decode(video_path, num_frames=num_frames) result.decode_results = {k: v.to_dict() for k, v in decode_results.items()} - # Step 3: Compute/Inference benchmark - report_progress(3, 6, _("Step 3/6 — Testing inference performance...")) + # Step 4: Compute/Inference benchmark + report_progress(4, TOTAL_BENCHMARK_STEPS, _("Step 4/7 — Testing inference performance...")) compute_results = {} if model_path and profile.openvino_available: @@ -829,8 +919,8 @@ def report_progress(step: int, total: int, message: str): result.compute_results = {k: v.to_dict() for k, v in compute_results.items()} - # Step 4: Pipeline Live benchmark - report_progress(4, 6, _("Step 4/6 — Testing live camera pipeline...")) + # Step 5: Pipeline Live benchmark + report_progress(5, TOTAL_BENCHMARK_STEPS, _("Step 5/7 — Testing live camera pipeline...")) pipeline_live_results = {} if video_path and model_path and profile.openvino_available: available = _get_benchmark_devices(profile) @@ -843,8 +933,8 @@ def report_progress(step: int, total: int, message: str): result.pipeline_live_results = {k: v.to_dict() for k, v in pipeline_live_results.items()} - # Step 5: Pipeline Batch benchmark - report_progress(5, 6, _("Step 5/6 — Testing batch processing...")) + # Step 6: Pipeline Batch benchmark + report_progress(6, TOTAL_BENCHMARK_STEPS, _("Step 6/7 — Testing batch processing...")) pipeline_batch_results = {} if video_path and model_path and profile.openvino_available: available = _get_benchmark_devices(profile) @@ -865,8 +955,8 @@ def report_progress(step: int, total: int, message: str): result.pipeline_batch_results = {k: v.to_dict() for k, v in pipeline_batch_results.items()} - # Step 6: Generate recommendations - report_progress(6, 6, _("Step 6/6 — Generating recommendations...")) + # Step 7: Generate recommendations + report_progress(7, TOTAL_BENCHMARK_STEPS, _("Step 7/7 — Generating recommendations...")) result.recommendation = _generate_recommendation( profile, compute_results, @@ -877,6 +967,20 @@ def report_progress(step: int, total: int, message: str): result.benchmark_duration_s = time.perf_counter() - start_time + # Nothing was actually timed: no OpenVINO model to run and no CUDA device. + # The recommendation is then a default dressed as a measurement, and the + # give-away is an estimated FPS of exactly zero. Saying so lets + # save_benchmark_cache refuse to freeze it for the life of the install. + result.inconclusive = not compute_results and not pipeline_live_results + if result.inconclusive: + log.warning( + "benchmark.inconclusive", + openvino_available=profile.openvino_available, + cuda_available=profile.cuda_available, + had_model=model_path is not None, + had_video=video_path is not None, + ) + log.info( "benchmark.completed", duration_s=round(result.benchmark_duration_s, 1), @@ -889,8 +993,14 @@ def report_progress(step: int, total: int, message: str): def get_benchmark_cache_path() -> Path: - """Get path to cached benchmark results.""" - return Path("openvino_model_cache") / "system_benchmark.json" + """Path to the cached benchmark result. + + Anchored at the repository root so it names the same file that + ``--reset`` deletes (``core/app_runner._perform_reset`` resolves it that + way). While this was relative to the working directory the two could point + at different files, and a reset that reported success changed nothing. + """ + return repo_root() / OPENVINO_CACHE_DIRNAME / "system_benchmark.json" def load_cached_benchmark() -> SystemBenchmarkResult | None: @@ -927,7 +1037,18 @@ def load_cached_benchmark() -> SystemBenchmarkResult | None: def save_benchmark_cache(result: SystemBenchmarkResult) -> None: - """Save benchmark results to cache.""" + """Save benchmark results to cache, unless nothing was measured. + + A benchmark that timed nothing still produces a recommendation, and caching + it is worse than having no cache at all: the file exists, so the benchmark + never runs again, and the machine stays configured off a measurement that + never happened. Repeating the attempt next launch costs seconds; freezing + the wrong answer costs the life of the install. + """ + if result.inconclusive: + log.warning("benchmark.cache_skipped", reason="inconclusive") + return + cache_path = get_benchmark_cache_path() cache_path.parent.mkdir(parents=True, exist_ok=True) @@ -943,6 +1064,7 @@ def get_or_run_benchmark( force_rerun: bool = False, quick_mode: bool = False, progress_callback: Callable[[int, int, str], None] | None = None, + settings_obj: Any | None = None, ) -> SystemBenchmarkResult: """ Get benchmark results from cache or run new benchmark if needed. @@ -951,6 +1073,8 @@ def get_or_run_benchmark( force_rerun: If True, ignore cache and run new benchmark. quick_mode: If True, run faster benchmark with fewer iterations. progress_callback: Optional callback for progress updates. + settings_obj: Settings forwarded to :func:`run_adaptive_benchmark` so it + can convert a weight when no OpenVINO model exists yet. Returns: SystemBenchmarkResult with recommendations. @@ -960,7 +1084,11 @@ def get_or_run_benchmark( if cached: return cached - result = run_adaptive_benchmark(quick_mode=quick_mode, progress_callback=progress_callback) + result = run_adaptive_benchmark( + quick_mode=quick_mode, + progress_callback=progress_callback, + settings_obj=settings_obj, + ) save_benchmark_cache(result) return result diff --git a/tests/core/test_app_runner_splash_ordering.py b/tests/core/test_app_runner_splash_ordering.py new file mode 100644 index 00000000..59479019 --- /dev/null +++ b/tests/core/test_app_runner_splash_ordering.py @@ -0,0 +1,107 @@ +"""The splash must appear before anything expensive is imported. + +Measured in ``logs/analysis.log``, a first launch spent roughly four seconds +between the process starting and the splash being drawn. Two import costs sat in +front of it, both looking like ordinary ``import`` lines: + +* ``from zebtrack.utils import set_seed`` -- ``zebtrack.utils`` imports torch at + module level, about 1.1 s. +* the ``di_registrations`` block -- it eagerly builds the coordinator/service + graph, pulling ultralytics, cv2 and matplotlib, about 3 s. + +Under the desktop shortcut the app runs through ``pythonw.exe``, so there is no +console either: a double-click produced four seconds of nothing at all, which is +indistinguishable from a dead icon. + +These tests pin the ordering rather than the duration. A timing assertion would +be flaky on a loaded machine and would not say *why* it regressed; the order of +the calls is the thing that must not drift back. +""" + +from __future__ import annotations + +from unittest.mock import MagicMock + + +def _run_until_container(monkeypatch, order: list[str]): + """Drive ``run_app`` far enough to observe startup ordering, then stop.""" + import sys + + import zebtrack.core.app_runner as app_runner + + # run_app parses sys.argv; under pytest that is the pytest command line. + monkeypatch.setattr(sys, "argv", ["zebtrack"]) + + settings = MagicMock() + settings.reproducibility.seed = 42 + settings.camera.index = 0 + settings.yolo_model.path = "model.pt" + settings.openvino.auto_benchmark = False + settings.ui.language = "en" + + monkeypatch.setattr("zebtrack.settings.load_settings", lambda: settings) + monkeypatch.setattr(app_runner, "_setup_logging", lambda *a, **k: (lambda _s: None)) + monkeypatch.setattr(app_runner, "_select_language_on_first_run", lambda *a, **k: None) + monkeypatch.setattr(app_runner, "_require_detector_weights", lambda *a, **k: None) + monkeypatch.setattr(app_runner, "_set_windows_app_id", lambda *a, **k: None) + monkeypatch.setattr(app_runner, "install_tk_exception_handler", lambda *a, **k: None) + monkeypatch.setattr("zebtrack.i18n.install", lambda *a, **k: None) + monkeypatch.setattr("zebtrack.ui.icon_utils.set_window_icon", lambda *a, **k: None) + + def make_splash(parent): + order.append("splash") + return MagicMock() + + def note_set_seed(seed): + order.append("set_seed") + + def stop(context): + order.append("build_container") + raise RuntimeError("stop here") + + monkeypatch.setattr("zebtrack.ui.splash_screen.create_splash", make_splash) + monkeypatch.setattr("zebtrack.utils.set_seed", note_set_seed) + monkeypatch.setattr("zebtrack.core.di_registrations.build_container", stop) + + tk_module = MagicMock() + messagebox_module = MagicMock() + + # run_app swallows the RuntimeError into its own fatal-error handler, so + # the call returns normally; the recorded order is what we assert on. + app_runner.run_app( + tk_module=tk_module, + messagebox_module=messagebox_module, + configure_logging_fn=lambda: None, + ) + return order + + +def test_splash_is_created_before_torch_is_imported(monkeypatch): + """``set_seed`` is the first thing that pulls torch. The splash precedes it.""" + order = _run_until_container(monkeypatch, []) + + assert "splash" in order, "the splash was never created" + assert "set_seed" in order, "the run stopped before the seed was set" + assert order.index("splash") < order.index("set_seed") + + +def test_splash_is_created_before_the_container_is_built(monkeypatch): + """The di_registrations import is the three-second cost. It comes after.""" + order = _run_until_container(monkeypatch, []) + + assert order.index("splash") < order.index("build_container") + + +def test_splash_module_stays_cheap_to_import(): + """The splash's own imports must not grow into the gap it exists to close. + + A heavy import added to ``ui/splash_screen.py`` would move the dead time + back in front of the window without changing a single line of app_runner. + """ + import inspect + + import zebtrack.ui.splash_screen + + source = inspect.getsource(zebtrack.ui.splash_screen) + for forbidden in ("import torch", "import ultralytics", "import cv2", "import matplotlib"): + assert forbidden not in source, f"splash_screen.py must not {forbidden}" diff --git a/tests/core/test_weight_manager_discovery.py b/tests/core/test_weight_manager_discovery.py new file mode 100644 index 00000000..0f9edaab --- /dev/null +++ b/tests/core/test_weight_manager_discovery.py @@ -0,0 +1,140 @@ +"""Discovery of weight files in the weights folder. + +``WeightManager.discover_weights()`` used to glob only ``best_*_lateral.pt`` and +``best_*_topdown.pt``. The two generalists shipped in the same release -- +``best_oi.pt`` and ``best_seg.pt`` -- matched neither pattern, so they were +invisible to the catalogue even when present on disk. That is why the installer +did not download them: fetching a file nothing could select was pointless. + +These tests pin both halves of the fix: everything named ``best_*.pt`` is +registered, and a generalist never takes a default slot away from a +perspective-trained specialist. +""" + +from __future__ import annotations + +import os +import tempfile +from pathlib import Path +from unittest.mock import Mock + +import pytest + +from zebtrack.core.services.weight_manager import WeightManager + +SPECIALISTS = ( + "best_seg_lateral.pt", + "best_det_lateral.pt", + "best_seg_topdown.pt", + "best_det_topdown.pt", +) +GENERALISTS = ("best_oi.pt", "best_seg.pt") + + +def _blank_settings() -> Mock: + """Settings that name no weight, so only discovery populates the catalogue.""" + settings = Mock() + settings.yolo_model.path = None + settings.weights.lateral.seg_filename = None + settings.weights.lateral.det_filename = None + settings.weights.top_down.seg_filename = None + settings.weights.top_down.det_filename = None + return settings + + +@pytest.fixture +def populated_weights_dir(): + """A weights folder holding all six published models.""" + with tempfile.TemporaryDirectory() as temp_dir: + for name in SPECIALISTS + GENERALISTS: + with open(os.path.join(temp_dir, name), "w", encoding="utf-8") as handle: + handle.write("mock checkpoint") + yield temp_dir + + +def test_all_six_published_models_are_registered(populated_weights_dir): + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + for name in SPECIALISTS + GENERALISTS: + assert name in manager.weights, f"{name} was not discovered" + + +def test_generalists_never_claim_a_default_slot(populated_weights_dir): + """A generalist must not displace a perspective-trained specialist. + + The specialists are trained for one camera angle; a lateral model on a + top-down scene returns nothing. Auto-promoting the generic weight to the + default would degrade every run that never opened the model panel. + """ + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + for name in GENERALISTS: + entry = manager.weights[name] + assert entry["is_default"] is False + assert entry["is_default_seg"] is False + assert entry["is_default_det"] is False + assert entry["perspective"] is None + + +def test_specialists_keep_their_type_defaults(populated_weights_dir): + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + assert manager.weights["best_seg_lateral.pt"]["is_default_seg"] is True + assert manager.weights["best_det_lateral.pt"]["is_default_det"] is True + assert manager.weights["best_seg_lateral.pt"]["perspective"] == "lateral" + assert manager.weights["best_det_topdown.pt"]["perspective"] == "top_down" + + +def test_generalists_are_classified_by_type_not_ignored(populated_weights_dir): + """Registering them is only useful if the type is right. + + ``best_seg.pt`` is segmentation and ``best_oi.pt`` is detection; a + misclassified entry would offer the wrong model in the wrong slot. + """ + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + assert manager.weights["best_seg.pt"]["type"] == "seg" + assert manager.weights["best_oi.pt"]["type"] == "det" + + +def test_a_generalist_can_be_promoted_by_hand(populated_weights_dir): + """Opting in is the point: discovery makes them selectable, not default.""" + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + manager.set_default_weight_by_type("best_seg.pt", "seg") + + name, _details = manager.get_default_seg_weight() + assert name == "best_seg.pt" + + +def test_discovery_counts_only_new_files(populated_weights_dir): + """A second sweep must not re-register what is already catalogued.""" + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + assert manager.discover_weights() == 0 + + +def test_discovered_path_points_at_the_file_that_was_found(populated_weights_dir): + """Identity, asserted by resolution rather than by string equality. + + The registered path is built from the directory listing, which need not + spell the directory the way the caller did -- on Windows a temp folder is + listed in its 8.3 short form while tempfile reports the long one. Callers + that compare these as strings are asserting a spelling, not a file. + """ + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + for name in SPECIALISTS + GENERALISTS: + registered = Path(manager.weights[name]["path"]) + assert registered.is_absolute() + assert registered.resolve() == (Path(populated_weights_dir) / name).resolve() + + +def test_unrelated_pt_files_are_left_alone(populated_weights_dir): + """The glob is ``best_*.pt``; a stray checkpoint is not a catalogued model.""" + with open(os.path.join(populated_weights_dir, "yolov8n.pt"), "w", encoding="utf-8") as handle: + handle.write("mock checkpoint") + + manager = WeightManager(settings_obj=_blank_settings(), config_dir=populated_weights_dir) + + assert "yolov8n.pt" not in manager.weights diff --git a/tests/core/test_weight_type_classification.py b/tests/core/test_weight_type_classification.py index 10a03a40..af965905 100644 --- a/tests/core/test_weight_type_classification.py +++ b/tests/core/test_weight_type_classification.py @@ -4,6 +4,7 @@ import os import tempfile +from pathlib import Path from zebtrack.core.services.weight_manager import WeightManager @@ -123,17 +124,29 @@ def test_get_weight_path_by_method(): wm = WeightManager(settings_obj=mock_settings, config_dir=temp_dir) - # Add both weights and set as defaults for their types - wm.add_weight(seg_file, set_as_default=True) - wm.add_weight(det_file, set_as_default=False) + # discover_weights() registers both on construction -- it globs + # best_*.pt, not just the perspective suffixes. As generalists they + # claim no default slot, so the defaults are assigned explicitly. + assert "best_seg.pt" in wm.weights + assert "best_oi.pt" in wm.weights + assert wm.weights["best_seg.pt"]["is_default_seg"] is False + wm.set_default_weight_by_type("best_seg.pt", "seg") wm.set_default_weight_by_type("best_oi.pt", "det") - # Test getting paths by method + # Compared as resolved paths, not as strings. The registered path now + # comes from discovery rather than from the exact string this test + # handed to add_weight, and on the Windows CI runner the two disagree + # in spelling: tempfile hands out the long user name while the + # directory listing yields the 8.3 short form ("RUNNER~1"). Same file, + # and the question here is which file was resolved, not how the path + # happens to be spelled. seg_path = wm.get_weight_path_by_method("seg", "aquarium") - assert seg_path == seg_file + assert seg_path is not None + assert Path(seg_path).resolve() == Path(seg_file).resolve() det_path = wm.get_weight_path_by_method("det", "animal") - assert det_path == det_file + assert det_path is not None + assert Path(det_path).resolve() == Path(det_file).resolve() # Test invalid method invalid_path = wm.get_weight_path_by_method("invalid", "task") diff --git a/tests/test_fetch_weights.py b/tests/test_fetch_weights.py index 711c3d8a..3bce5dad 100644 --- a/tests/test_fetch_weights.py +++ b/tests/test_fetch_weights.py @@ -212,10 +212,12 @@ def test_round_trip_classifies_and_reloads(self): self.assertEqual(by_name["best_det_topdown.pt"].perspective, "top_down") self.assertEqual(by_name["best_det_topdown.pt"].weight_type, "det") - # Only perspective-suffixed weights are auto-discovered by - # WeightManager, so only those are required. + # Every model in the manifest is fetched by default. The flat-named + # generalists used to be marked optional because WeightManager could + # not discover them; it can now, so a standard install carries all + # six rather than four. self.assertTrue(by_name["best_seg_lateral.pt"].required) - self.assertFalse(by_name["best_seg.pt"].required) + self.assertTrue(by_name["best_seg.pt"].required) self.assertIsNone(by_name["best_seg.pt"].perspective) self.assertEqual(by_name["best_seg_lateral.pt"].sha256, _PAYLOAD_SHA) @@ -345,7 +347,7 @@ def test_missing_manifest_reports_an_error_code(self): class TestShippedManifest(unittest.TestCase): """The manifest committed to the repository must stay loadable and coherent.""" - def test_repository_manifest_loads_and_requires_all_four_perspectives(self): + def test_repository_manifest_requires_every_published_model(self): manifest = load_manifest() self.assertEqual(manifest.repository, "MarkSant/DRerio-LogAI") @@ -358,9 +360,13 @@ def test_repository_manifest_loads_and_requires_all_four_perspectives(self): "best_det_lateral.pt", "best_seg_topdown.pt", "best_det_topdown.pt", + "best_oi.pt", + "best_seg.pt", }, - "both perspectives need a seg and a det weight; a lateral model on a " - "top-down scene returns zero polygons", + "both perspectives need a seg and a det weight -- a lateral model on " + "a top-down scene returns zero polygons -- and the two generalists " + "ship with them so the model configuration panel has something to " + "offer beyond the four specialists", ) def test_every_entry_has_a_plausible_digest_and_size(self): @@ -370,22 +376,23 @@ def test_every_entry_has_a_plausible_digest_and_size(self): self.assertRegex(entry.sha256, r"^[0-9a-f]{64}$") self.assertGreater(entry.size_bytes, 1_000_000, "a YOLO checkpoint is megabytes") - def test_required_names_match_the_discovery_globs(self): - """WeightManager only auto-discovers best_*_lateral.pt / best_*_topdown.pt. + def test_required_names_match_the_discovery_glob(self): + """WeightManager.discover_weights() globs ``best_*.pt``. - A required entry whose name misses those globs would download fine and + A required entry whose name misses that glob would download fine and then be ignored by the catalogue, which is indistinguishable from not - having downloaded it at all. + having downloaded it at all. That is exactly what happened to + ``best_oi.pt`` and ``best_seg.pt`` while discovery matched only the + perspective suffixes. """ for entry in load_manifest().entries: if not entry.required: continue with self.subTest(entry.name): self.assertTrue( - entry.name.endswith(("_lateral.pt", "_topdown.pt")), + entry.name.startswith("best_") and entry.name.endswith(".pt"), f"{entry.name} would never be auto-discovered", ) - self.assertTrue(entry.name.startswith("best_")) class TestUrlSchemeGuard(unittest.TestCase): diff --git a/tests/test_main_composition.py b/tests/test_main_composition.py index d1d79123..905791c5 100644 --- a/tests/test_main_composition.py +++ b/tests/test_main_composition.py @@ -251,10 +251,14 @@ def test_seed_is_set_when_configured(self, monkeypatch): set_seed_mock = MagicMock() monkeypatch.setattr("zebtrack.utils.set_seed", set_seed_mock) - # Make it fail early after seed is set + # Stop right after the seed is set. This used to throw from + # create_splash, but the splash is now built BEFORE set_seed -- on + # purpose, so the window appears before zebtrack.utils drags torch in -- + # and stopping there would prove nothing about the seed. + monkeypatch.setattr("zebtrack.ui.splash_screen.create_splash", lambda parent: MagicMock()) monkeypatch.setattr( - "zebtrack.ui.splash_screen.create_splash", - lambda parent: (_ for _ in ()).throw(Exception("stop")), + "zebtrack.core.di_registrations.build_container", + lambda context: (_ for _ in ()).throw(Exception("stop")), ) try: @@ -280,9 +284,10 @@ def test_seed_not_set_when_not_configured(self, monkeypatch): set_seed_mock = MagicMock() monkeypatch.setattr("zebtrack.utils.set_seed", set_seed_mock) + monkeypatch.setattr("zebtrack.ui.splash_screen.create_splash", lambda parent: MagicMock()) monkeypatch.setattr( - "zebtrack.ui.splash_screen.create_splash", - lambda parent: (_ for _ in ()).throw(Exception("stop")), + "zebtrack.core.di_registrations.build_container", + lambda context: (_ for _ in ()).throw(Exception("stop")), ) try: diff --git a/tests/ui/dialogs/test_welcome_dialog.py b/tests/ui/dialogs/test_welcome_dialog.py new file mode 100644 index 00000000..e50c2017 --- /dev/null +++ b/tests/ui/dialogs/test_welcome_dialog.py @@ -0,0 +1,166 @@ +"""Tests for the getting-started window. + +The window is informational, which is exactly why the interesting assertions +are about what it must NOT do: it must not persist anything unless asked, must +not write the whole settings tree when it does, and must not take the +application down when hardware detection or Tk misbehaves. +""" + +from unittest.mock import MagicMock, patch + +import pytest + +from zebtrack.ui.dialogs.welcome_dialog import ( + WelcomeDialog, + _hardware_advice, + should_show_welcome, +) + +pytestmark = pytest.mark.gui + + +class TestShouldShowWelcome: + """The gate that decides whether the window appears at startup.""" + + def test_true_when_the_setting_is_enabled(self): + settings = MagicMock() + settings.ui.show_welcome = True + + assert should_show_welcome(settings) is True + + def test_false_when_the_setting_is_disabled(self): + settings = MagicMock() + settings.ui.show_welcome = False + + assert should_show_welcome(settings) is False + + def test_false_when_settings_have_no_ui_section(self): + settings = MagicMock() + settings.ui = None + + assert should_show_welcome(settings) is False + + def test_a_bare_mock_does_not_count_as_enabled(self): + """A MagicMock answers every attribute truthily. + + Trusting ``getattr`` here would pop a modal window over the main window + in any test or embedding that passes a stub settings object. + """ + assert should_show_welcome(MagicMock()) is False + + def test_a_truthy_non_boolean_does_not_count_as_enabled(self): + settings = MagicMock() + settings.ui.show_welcome = "yes" + + assert should_show_welcome(settings) is False + + +class TestHardwareAdvice: + """The OpenVINO guidance is read off the machine, not written in general.""" + + def test_nvidia_machine_is_told_to_leave_openvino_off(self): + summary = {"cuda_available": True, "openvino_available": True, "openvino_devices": ["CPU"]} + with patch("zebtrack.utils.hardware_detection.get_hardware_summary", return_value=summary): + headline, detail = _hardware_advice() + + assert "NVIDIA" in headline + assert "CUDA" in detail + + def test_intel_machine_is_told_openvino_is_the_fast_path(self): + summary = { + "cuda_available": False, + "openvino_available": True, + "openvino_devices": ["CPU", "GPU", "NPU"], + } + with patch("zebtrack.utils.hardware_detection.get_hardware_summary", return_value=summary): + headline, detail = _hardware_advice() + + assert "OpenVINO" in headline + assert "NPU" in detail, "the detected devices should be named, not described abstractly" + + def test_machine_with_no_acceleration_is_told_so(self): + summary = {"cuda_available": False, "openvino_available": False, "openvino_devices": []} + with patch("zebtrack.utils.hardware_detection.get_hardware_summary", return_value=summary): + headline, _detail = _hardware_advice() + + assert "acceleration" in headline.lower() + + def test_detection_failure_degrades_to_generic_advice(self): + """Hardware probing touches optional native libraries; it can throw.""" + with patch( + "zebtrack.utils.hardware_detection.get_hardware_summary", + side_effect=RuntimeError("openvino exploded"), + ): + headline, detail = _hardware_advice() + + assert headline + assert "OpenVINO" in detail + + +class TestWelcomeDialogBehaviour: + """Driving the real widget, on a real Toplevel.""" + + def test_closing_without_the_checkbox_persists_nothing(self, tkinter_root): + dialog = WelcomeDialog(tkinter_root) + + with patch("zebtrack.settings.write_local_override") as write: + dialog._close() + + write.assert_not_called() + + def test_checking_the_box_writes_only_that_one_key(self, tkinter_root): + """Never ``save_settings``. + + ``save_settings`` dumps the whole resolved tree into config.local.yaml, + which freezes every current default and permanently defeats the layered + config.yaml -> config.local.yaml merge for that installation. + """ + dialog = WelcomeDialog(tkinter_root) + dialog.dont_show_again.set(True) + + with patch("zebtrack.settings.write_local_override") as write: + dialog._close() + + write.assert_called_once_with({"ui": {"show_welcome": False}}) + + def test_a_failed_write_does_not_propagate(self, tkinter_root): + """Showing the window again next launch beats taking the app down.""" + dialog = WelcomeDialog(tkinter_root) + dialog.dont_show_again.set(True) + + with patch( + "zebtrack.settings.write_local_override", + side_effect=OSError("read-only filesystem"), + ): + dialog._close() # must not raise + + def test_opening_model_settings_invokes_the_callback(self, tkinter_root): + callback = MagicMock() + dialog = WelcomeDialog(tkinter_root, on_open_model_settings=callback) + + dialog._open_model_settings() + + callback.assert_called_once_with() + assert dialog.opened_model_settings is True + + def test_the_callback_runs_after_the_window_is_gone(self, tkinter_root): + """The panel must not open while this window still holds the grab.""" + seen: dict[str, bool] = {} + + def callback(): + seen["window_exists"] = bool(dialog.window.winfo_exists()) + + dialog = WelcomeDialog(tkinter_root, on_open_model_settings=callback) + dialog._open_model_settings() + + assert seen["window_exists"] is False + + def test_dismissing_with_the_box_ticked_still_persists(self, tkinter_root): + """Escape and the window's X go through the same path as "Not now".""" + dialog = WelcomeDialog(tkinter_root) + dialog.dont_show_again.set(True) + + with patch("zebtrack.settings.write_local_override") as write: + dialog._close() + + assert write.call_count == 1 diff --git a/tests/utils/test_hardware_benchmark_cache.py b/tests/utils/test_hardware_benchmark_cache.py index 3bc2c99e..4baaa6db 100644 --- a/tests/utils/test_hardware_benchmark_cache.py +++ b/tests/utils/test_hardware_benchmark_cache.py @@ -127,3 +127,67 @@ def test_print_benchmark_summary(capsys): assert "HARDWARE BENCHMARK SUMMARY" in out assert "CPU: Test CPU" in out assert "GPU: Test GPU" in out + + +def test_inconclusive_result_is_not_cached(tmp_path, monkeypatch): + """A benchmark that measured nothing must not freeze its guess forever. + + On a fresh install there is no converted OpenVINO model, so every + measurement step used to be skipped: the run finished in 0.2 s and + recommended CPU at 0.0 FPS. Caching that was worse than having no cache -- + the file existed, so the benchmark never ran again and the machine stayed + configured off a measurement that never happened. + """ + cache_path = tmp_path / "bench.json" + monkeypatch.setattr(hb, "get_benchmark_cache_path", lambda: cache_path) + + result = _make_result(fingerprint="abc") + result.inconclusive = True + + hb.save_benchmark_cache(result) + + assert not cache_path.exists() + + +def test_conclusive_result_is_still_cached(tmp_path, monkeypatch): + """The guard must be narrow: a real measurement is cached as before.""" + cache_path = tmp_path / "bench.json" + monkeypatch.setattr(hb, "get_benchmark_cache_path", lambda: cache_path) + + result = _make_result(fingerprint="abc") + result.inconclusive = False + + hb.save_benchmark_cache(result) + + assert cache_path.exists() + + +def test_inconclusive_flag_survives_a_round_trip(tmp_path, monkeypatch): + """It is serialized, so a cache written by an older build reads as False.""" + cache_path = tmp_path / "bench.json" + monkeypatch.setattr(hb, "get_benchmark_cache_path", lambda: cache_path) + + result = _make_result(fingerprint="abc") + monkeypatch.setattr(hb, "detect_hardware_profile", lambda: result.hardware) + hb.save_benchmark_cache(result) + + loaded = hb.load_cached_benchmark() + + assert loaded is not None + assert loaded.inconclusive is False + + +def test_cache_path_is_anchored_at_the_repository_root(): + """Not the working directory. + + ``core/app_runner._perform_reset`` deletes this file resolved against the + repository root. While the two disagreed, ``--reset`` could report success + after deleting a file the application never wrote. + """ + from zebtrack.paths import repo_root + + path = hb.get_benchmark_cache_path() + + assert path.is_absolute() + assert path.parent.parent == repo_root() + assert path.name == "system_benchmark.json" diff --git a/weights_manifest.json b/weights_manifest.json index 25dc227a..872c95e6 100644 --- a/weights_manifest.json +++ b/weights_manifest.json @@ -25,7 +25,7 @@ "bytes": 19220903, "type": "det", "perspective": null, - "required": false + "required": true }, { "name": "best_seg.pt", @@ -33,7 +33,7 @@ "bytes": 20568005, "type": "seg", "perspective": null, - "required": false + "required": true }, { "name": "best_seg_lateral.pt",