From c91c537b7d06c46dd0e459f8c1bd6b621e7bec5a Mon Sep 17 00:00:00 2001 From: MarkSant Date: Tue, 8 Sep 2026 16:56:16 -0300 Subject: [PATCH 1/2] =?UTF-8?q?feat(startup):=20a=20primeira=20execu=C3=A7?= =?UTF-8?q?=C3=A3o=20era=20muda,=20incompleta=20e=20sem=20rumo?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Três lacunas no intervalo entre o duplo-clique e a primeira análise. O splash era criado DEPOIS do bloco de imports que monta o grafo de coordinators, então a janela só aparecia com o carregamento pesado já terminado. Sob o atalho (pythonw.exe, sem console) isso é indistinguível de um ícone morto. Dois custos estavam na frente dele, ambos com cara de import comum: `zebtrack.utils` importa torch em corpo de módulo (~1,1 s) e `di_registrations` puxa ultralytics, cv2 e matplotlib por cima (~3 s) — medido em logs/analysis.log entre application.starting e benchmark.running_first_time. O splash agora vem antes dos dois. O benchmark não era o gargalo que parecia: ele levava 0,2 s porque não media NADA. As etapas 3-5 são guardadas por `model_path`, e numa instalação nova openvino_model_cache/ não existe, então todas eram puladas. Recomendava CPU a 0.0 FPS, cacheava isso e nunca mais recalculava. Agora converte um modelo antes de medir — trabalho que a aplicação pagaria de qualquer forma na primeira análise — e um resultado que ainda assim não mediu nada é marcado inconclusivo e não vai para o cache. As 7 etapas também ganharam tempo mínimo em tela: elas sempre foram enviadas ao splash, mas várias terminam em milissegundos e eram sobrescritas dentro do mesmo quadro. A instalação trazia 4 dos 6 pesos. A razão registrada era circular: os generalistas ficavam de fora do download PORQUE o catálogo não sabia descobri-los — a varredura pegava só best_*_lateral.pt e best_*_topdown.pt. O defeito real era a lacuna de descoberta. Corrigida, o download deixou de precisar modelá-la. Um generalista é registrado mas nunca toma um slot padrão: os especialistas são treinados para um ângulo específico. E o painel de configuração de modelos não tinha entrada de menu nenhuma — a única tela que o operador precisa antes da primeira análise era a que ele não conseguia achar. Agora há uma janela de primeiros passos depois da principal, que lê o hardware detectado em vez de dar conselho genérico sobre OpenVINO, mais Configurações -> Definições de modelo e Ajuda -> Primeiros passos. Também ancora em repo_root() os três caminhos do benchmark que eram relativos ao CWD: pelo atalho concordavam por sorte, de outro diretório --reset apagava um arquivo e o app escrevia outro. Co-Authored-By: Claude Opus 5 --- CHANGELOG.md | 95 +++++++ CITATION.cff | 4 +- README.md | 6 +- README.pt-BR.md | 6 +- config.yaml | 15 +- docs/releases/RELEASE_NOTES_v7.1.0.md | 130 +++++++++ docs/tasks/active/REFACTOR_GIANTS.md | 2 +- docs/wiki/1_Installation.md | 21 +- install.ps1 | 2 +- pyproject.toml | 2 +- setup.sh | 2 +- src/zebtrack/constants.py | 6 + src/zebtrack/core/app_runner.py | 104 +++++-- src/zebtrack/core/services/weight_manager.py | 106 ++++--- src/zebtrack/fetch_weights.py | 20 +- .../locales/pt_BR/LC_MESSAGES/zebtrack.mo | Bin 279824 -> 284334 bytes .../locales/pt_BR/LC_MESSAGES/zebtrack.po | 84 ++++++ src/zebtrack/settings.py | 11 + src/zebtrack/ui/components/menu_manager.py | 16 ++ src/zebtrack/ui/dialogs/welcome_dialog.py | 261 ++++++++++++++++++ src/zebtrack/ui/gui.py | 32 +++ src/zebtrack/ui/splash_screen.py | 22 ++ src/zebtrack/utils/hardware_benchmark.py | 172 ++++++++++-- tests/core/test_app_runner_splash_ordering.py | 107 +++++++ tests/core/test_weight_manager_discovery.py | 123 +++++++++ tests/core/test_weight_type_classification.py | 10 +- tests/test_fetch_weights.py | 31 ++- tests/test_main_composition.py | 15 +- tests/ui/dialogs/test_welcome_dialog.py | 166 +++++++++++ tests/utils/test_hardware_benchmark_cache.py | 64 +++++ weights_manifest.json | 4 +- 31 files changed, 1499 insertions(+), 140 deletions(-) create mode 100644 docs/releases/RELEASE_NOTES_v7.1.0.md create mode 100644 src/zebtrack/ui/dialogs/welcome_dialog.py create mode 100644 tests/core/test_app_runner_splash_ordering.py create mode 100644 tests/core/test_weight_manager_discovery.py create mode 100644 tests/ui/dialogs/test_welcome_dialog.py 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 9e56830b066b82e9b92cd1173b18f22a6456eeee..a4cedcc38793f98eedcde76c99d3244f604c6713 100644 GIT binary patch delta 44157 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 lQFlXv?~?JDkN!z4 zf?qJV>c8+k`##oS!e2z+*H>kA?y-%ckd{e};0AOmm){V}MB zWq4>2D1xQvS3_n0cy!fRNh1OedOk!AB+(<=FtVb~TZYPweW=cB4*>r?7qWpRW9k$4uBtshV?i8Rlwtg2vs`ah!Teg`Tk zAE2&_|J=R<7DNrCJvPUw_!a((x-R#>mP^fWEd3w1v@u zJrFgc&8W3Li^_>Rs4ez`_j$!vUKye$Fcnp9`>`bcje3b?er@~7*RIzXhDyTisQ36` z)JAg-RbDsoFy2L-x9*J%U^gma7f?&~8a2SUZ*A?VF`O?0hf|HM1?j5BT$Z)JRXF9()@E_zsnPslQlGlt7hP6V%LmqaryT zwE-Q(zW4%_tR4Tea-Jf2zB7x4%3^`{!8X(uxf?apQ>X_#N8KPc--$JVDAecmP&4j_ zT7t2tiOfZ9M2k@oz2F`H8#SRv=r*Ad;`0Yb-W>JdzL*C`pt>V=RH+`2E40nuxLJ&qgKdV!z-1{_i#pXoES98reNmwthkFcQ4?$fw(>wl)*Z&Qb zGiOlub6?QV%;Lqh8DvC7APTicoxS73F+csO82py(c?WZF{6ADKVC&Dw(9>H4b|;GsHFLfTB`(!{K2ia1S)GQc>Nk)zdmZG zY=L#LBWjP|gVFdIHIV#?{lOn-N@ExLvoI!xC1K#I|M)btGiAbHa(NCyW%)wXYj-C$ z$N!Ni)>TsS7S60^?BEv9ja^xpmJqA2H*b+X(%K+F@VQBAE9y} zUJAQWCe+fD_H2h*)A6VWEJDp}J?ehjFe4sD4e$YK05MbAb!k#k|2i=b2Lf0U6@eD0 z6NjU2JOi`hGR%O-Js+V45I2?I*~QE=phA8vHPu7^0ct-wCeGyL0BVBncp5rkH7dytqC)<_>%a5*=`!1ca^VO*FOAc6K7NgjvesZ(_U4J`XQr!A--Om?wJmiAYG4O(DxO1qJ8l>0 z?R*$Ue+VjqBT(nhMon-9YHQwydfssiRsG+fp*6dM+6Pi+vo(!EUHFyPuZs$CbJSY4 z!^$|s`}`2oDF*Am4-JKWtPbFMT#N@%A#0bzM%)+m z@)(T@?F`h7HlQAG4D;aMsI^U+)69<*=+{Ce-7HknZbA1e8hdHzK_9US#?56*Q4h1z z?~d9T=VE?bjk>{24B&lK1Y_s+2fu8V#&YzBVn#fO8rWUT%+e;$;}8BqvuIxGUm={7 z*JgGZE7AW4wGZUZXMTg)!+W9Xdm(B+SmPZ(hl<1(ER89ntn3=1j(5f1a1?3_E}!3W zYh!-uza0nmaG(c96tK73EL1seMwQW3EQ2BZ6*g_v968T*s?tzhHbRA_4gP{tQAtekgJqG`0xJOqrMT{M&0-eDl!F2TfYlxAj?o?yB&4@d0eEC{!2p- znp(ym{9nUbjXI%tSsPFzRKFXltj1#tJcBC7eC7PkMr@6mK#KAfnOvwOD}@EI6KY3Y zjIoH|T6DGHY^`9SK8~9CbyTvw!lszDq8%TBE9lSj`sFHF=$oK+#zB|@N1%4Vxu}7! z^*n~%>EA=`D-A1C|GIE^Wm}`MsN|TATKlD_q}+_X@d|1NHLBQt(HJw(Z;zVDDEyw8 z%}4F=X{-8!|JAJASe^brY>Y=xOPINuYjxhdnr$#Yc&^91oNyJjrg5s<=Vefl>5L_C zp?CZ`>b#gW{J}rvDv2fO&qkep36g{$FwZ`{Q+58e!9kCnpGaLiUgi5LjO)NKlLA@(ZV{jsoq^B&imsW z9FMAktSxMyMN!q&9F?4XrRsmQcfwrENPjOX`){Kzc!3&moR+rcQK%bKM4i_V^`MTZ zopUg10+FpOr>bIM`n^%-twAO4UJU;E-)9;MVWMv>)CEvUQVr{3JIsl@@D|=f&HPAf zi`*sj)AzNp(1)S!ml;)_6|pnPkYgGL=L4|fG zYVD_E1>At`@hz6e79H$IuVtwFT)|L$==lsa@K+c>e@E9snx&)d-?0<%l7{9CKLO~ovzY(s$3pf(pA>C|^ z{N3$6of%cfxlr%@La2?VBB~6Vpq8LF>b*V%HK4hu8U5;c40ZnBs0n;Vm3LSVTgoIz zIl4|(8rn#rQCseJs0-(!F4%-x`+Zmg&toOb+|#~(cS5}tH={m(hefecFT3wB98P~5 z>ORGK+dykzLZ0t*pz$3a^hZ7LFVu{mV;T(WW1$Y9lCc14rVUXy?1|bxenegOD=H#? zpsv4$+IZezZVc^fNthpl|NY-GG<2gTsQT}Qs?#Z`Ep!>`M!(}Qyot(%mi=rgI(d#p z4Qx58I`*O-7}lS+A{Ia`#YWV5JJIb<<2(&zdxZh^plYZQw?_@A59;$-UVowIK2-AE zLCyRXE}+h1547A_G1wlo4Hcn3Q91PybzR&c)W2q)Zivk&3N^Cgs2r$|%I0pU3nzHT z7oe7ED{7#>qpIak)cr!f_Xq#@Jq0T3r{Uk2kaE|3U!iu^_(N&vf{3AJQPkJt>Zlp@ zMJ4Ar)SB+cTzCnU3!%g8y6mW>D&g4_+t43`W$_l4!OX+$ozfPY(LdnQ(8vQLENg4w zSMP3%M$`jKjIsySK-GIIRHVK~?deld5!i@1@DeIAA)_sFc`=fHdDH|uqjuH> zsDbRjI;#KsG>USd_!ygUUsSfQMODEO&ugd)pL+dtV{JhBP!TAP8o)r*(yhSx_#2kQ zM&oSXn2oycEzF|&e@R1Sk!rl%Fo4N?Y)Gh)SDD}s{?+P%s0bWGW$#T~fX`7|_r!_* z;2+T(!s6;rvfuk1M@=x(WE((J)P0v>@bCX!prLOr|DclO6KX~!r+7<)`g|kmK}S)a z`=ip=(AYymCsdwc@A>+uIN-QFQ?(CEZ&GZ?-Xhyx{Jz*m#Be-Ewmjo zDJr+ppsFPkX2nXV33YX8w4kxXI}q}djj)hsXU`?50bEAqK&(Z!ACyK#uA|qVhg#c% zsEM7&0RDp-NQ}ic-~_0MxoK%6r;!hJVMWvo>!Y^fo~TfbN8NCS*Z&z6;`OK-AHZ+% z80z~&?j?3zVN_&FBlYgoMqT$ac2WKBu!a-4)V9hQs2N>GC0CqfR=){R1FV5M-W)Zs z*4Pv~qw4=C>Yechb)RI*Ete|dWcpoE1Nsk>sQwfGZ13YNs1FLDMpyxLqlVt`z8IiC z8WZ3u)J(Tydpv-8OXgT%Xn%NkP#I2~W<2SJb#`(po60Apm8fpW2fJ(ZotL@uyRqRE7GHSq~YpDO~G&<9W z#+{zePS+7wn5)P|4kAtLqQ`PbN&-N^<%5Z?bH&oh;9Gdq4%$%VQW8!hN1^QRSFt zhaGQ@3iS+B1a@LIe2E%R>7CvNg!<8JHfl+KbG^m`3}FB%c3J3B|K<<=$6_m>ULFfk zYkC=#l*xD7%nD#W`i)W9J`QW)Zq#?cgnKMP^>7sZF}M&D@3o1!ztT`yJVT9a=sw$k zmZ3&|6&2!y`|SbcP&4m?vv4^+#u5i?pgDfG5Vt{{cMU6I+=G@wwLQlok#d~_G*k{B zQ6b6xhglzWd<1644VVqDp=KI-$Yx#$E75O{`g}d={L84lKIE_+FM@hkv_icVM`3Q& z{|`rOkKc$Izr98Z_JMeu@}BYJ+RX;zta+@qh7nOQ3J?#+vNxGe;}voq8_;Wj4kmKRI(=ills>h7owpHI-%-zD(V-6dr&hzeAYIm57>!*t8@0H z@+9iU+0NUH8=^u!9Q9RgJ8B82lNRdm65!e**hppa1!tRrm;FGr$?QtbBjE zZ5!7+tjg!@?ogI|z7d<#f9L+?5B^KX27mj5|0*T@KO~z@z|ou#a?jq=&Qm%IDW*7P&R zryu#qEQtEN9A;&pjZrro_SjxdQ&A7viBs`1#>Gxg?0ZE&)P6DzsUH6RFAcqJpJOug zKlM91xN#~}a-Mx=@B5g~ZO4j-nqgJcz?yjdj@Xg@0Mvc{!msfisw~U>Yp?h3F(v)U z!9Ml3l12s&>_px0D(V6EgC}qU45c6Eg?)8Oh#BdJqh7})F&x{W>V5?3>-bvKm(C;J z@kgkaPV$%bb3|Sot@Uz;4*=jXmHb>cU%|FHj*2eQTi( z$ISGjJnNwbHUI-S9yOq~sE8ayU4QQ_^&g<|9|tt^z&qO_ze4qUp&~O8wL|`bG4TQ_ z0yjMWMP+-~dwWoN)B_5l&Z~}P$*ESTC0h2u&O7yi`qzd3a6k|Ef=Zt3A8ke@QCsb| zs4|-7_2-~|5IKhN@BykU-+29apDY5YQ8Uhk8cCY?vpq07DoHD&z8BQT+&CFy;~spDM^Ilr6p>Zs^lA^v)RKfuE z#d0_cHLz>g6CYs%Y#ui>_?yzLs3j~C&(^#)YG8vKoJ_s0lpwOq9T`k48nfCbq!N*b+~;G|JM*ozNcK9d(1T zsJdQ<3-CHBv;z~_4dI51*rSETWBa* zE}+Wm6>i1?$wGsFyZt^6qTfHcEyXLGNSz6Y}8)8*6aU)n#omEQocfk&Y#wfXGZ-TPy&@y z?NHZ`K~>9Q)P}VVXL0jOM9f?A?On1i+a2g}e; zl-@R$+Nh;#hlOxCR>SM4YRH};H272XFswztQ^4GfZg~zQ%xEF5i$m!zK>gS4(q{?{ z{vc8~vpuL6=I8TK_%-gtPZ*XZH28-~ArVA`>wZBE=wMdMc7J4O@D~oPu_MQi<25Xp zjr!N#ojAMg;dxP^tB%Tnwx}f;j#~R!-sdY&JK|o{Qd~fl-)mIF!gE-NOQXuQ4(dKb zQTLyLy5HIy)PFe|yE&k;@#VA|r9(ZSJd&mS2M6u*p{O;Sj{#hPI`0T-=e&t};9baYfv-W zhOzK8>b%RSiM>H3U6MR@zX;Sz$*oO8C$>YK_ycO>i%=uphl;>G&-i)m-B18kPGwL_ z)fjcdexBn{W%?5;c@Luoco+4&FBnk$hv%~glt8UrYvcoG6sp`-qFzSZFpG}}A^!z# z=V6pZ>TUsx%qP_K8KTW{c$o9rqb8EKklm*~D&lRhh3bC*4Xw!)ER4@lS)Q%1z2BRl zX7(d$#&c1T+JxHiPJ7uUz zLxVpHHbo8OIo`v}CG0Cz+>)WepYxNWcF2i11CL-AY+lOB?F8x_5nb9Wjha9;)CAh1 ztC;Y;Cd}S=u2~p?gM3*+3*JaZ%`Xko^m$gUZ@+7M@4EKD)a|X)p8#7pobV6KjLwgBCI^+ zjek}O4gP^d@~Sq$^BCZ~XH}_xWp|=#mL!Ex$Ds*mdmlY^cbUMQvd9z5WQ)1ZN=;cAcFxw1(GFH-3T2iFkEw zkB&qoO<`2X>Y-*h05{-N)CN?(UZ}GOTcPd~Qs34%95sOwr~%c*oY(M^zP8HSDAh^k9*kdv-A z79#6-FTX zGy?hxdEucT;HfV>AfAW>;yd@wdc8RGCF{Af_uTJ%=R4oImZh*uB28u7Fzgxjf(k{% zOu0~DJY_syW~pq6>iW;uZ+rV^7N*ZlpJ5l~=2?du7K&5^sR)1i<2z&P>7D;v*d42s znWpNvLwJMBcs>62#fdMtpzKo0H8xbM)dwz=OU5nbA0G6+y>E<%0E69vrJ4te^dRDf zj77fZE8cH+Y~O%at>$;1^M2`929W-C{dG2*h->=BB6OIRXmhY{-ZhX-XngjI{Ec)dbj;4;As*VgzYGea3xJFOIS_R z6@o3ROtZSwIFQFYS~k*D^TRKBU2j)wh}rz+cf5+nrIW}LNn07!y$B;fFvmBk5=T!qaoUsgJ3x|e6?&z&LU znaOw*En8B=xV8Z|6Gqi@*@>9fBIKL@rskdhe0OWKj*KD}E8VgIYebJDTOfUvSVD>M z3NwnBVF3_Eg15z>M5{9kc^~MMoc2F7p>}CVjYUgWXf8K6imkg z@*-1ls_c{F^6cFF{Yk_j;1K+pRPZo=_yov>;RVHL5ACp;m$9 z5dp1`G(mi55i3+6;4O#>CT+Va*cu~t)A zY@4{ImK^*|+}Q+xX3`-L;c1r0V9^#X;SxzwLM@&KO%xYKGV0o@WEdK5Av`%qzVkir zAD{7$9+~R>`2+95*N+{UVq^ewS|nWeO~f@G5o^11qwy!GY-DT|90rTyi!cM$l5v)b zL%joQ-jshFk-arHY$b;b?LAN&RWF>DsBsn?E3GG^@=1yKxB-V zriI+8b0(&<|JwUn8RKO7c2g%|04~>jP`Jc?lA4g`gP~jDfr!E7|M2nqOaa}et z&5O5-J4opnc23k~EX&8RM5i$G*fn_MwG1q`6m_tw=IiZ|d)|Th7Po`1wAs3nWl!^v z=kG6#d^EdnrnsrF&44itb8N#Fpw%A!yU6jp_tD5>Pma%NXijQ{mP7|ALxb>t*YcnJ zbL1DJPn_jiiw3e(jDn^pCva-aGc9ajm49Q0|Lm^O`=^5W_{Qh_J=>-(A%7a!C2r~w zHSpl#7M{>uGHxoSi`7KpJbo>c`FoH1pKLqm<|BAQW;lMMfrit6?Th~Q9gm!8D7au; z+{}M+1b^jtYJo!l{AxxMo@JmfP|}c1rYJ$k8Kx1O$r3`V#VR~aRP*j}{|#>pUJ%~5 zM)~fOego5UL#^Cl?2k_#Vi+4I{R)O>u?iJ{@N%Mt_)*XZz!;q&P_1GW>BfYv#h^SW z#!*!PGyTCw?_8&#y0q)4A%$=Vuuqzx%)zBq5Wy(QNe z;YgG~KrLV65fowfaYj~Uy#$LfnF4ecbmKtavv@0RWRcZjP`pJWcQ9uiXG93Dq{Z7^1g^M^Z4fY+ zwj~vA(PW9xQfXYMU@939L6^{|!!?+*1PhkH>RBAXhjQUK&`2TD0+b?=Ls} z@!qKw|I7Q`S1{p+ztY>i;vekAEB@gV?i-l;!`}cpnSfT@C6IoZI^*b2*;c*RSNvmp z>29!PQ}3@UBYSp|_q)J!ZCG9Lf4F}XI&`uiA(ll}`hhdOm%r-o?Y;Vr|Ji)*9X~z+ z8`(SwSer{++Ct@Aeca)K~q6ernsl67wwJ2`Vz;_FcZ8R eQX8W6uNC(0fg)N7`3l$h#NYi-uKXA2cgM^C delta 40082 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 Q`z>> 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..f81a6503 --- /dev/null +++ b/tests/core/test_weight_manager_discovery.py @@ -0,0 +1,123 @@ +"""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 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_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..ef8329cc 100644 --- a/tests/core/test_weight_type_classification.py +++ b/tests/core/test_weight_type_classification.py @@ -123,9 +123,13 @@ 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 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", From 97f04c085337cd5cfa962601a0e0584beaf35ad0 Mon Sep 17 00:00:00 2001 From: MarkSant Date: Tue, 8 Sep 2026 17:15:03 -0300 Subject: [PATCH 2/2] =?UTF-8?q?fix(test):=20comparar=20caminho=20de=20peso?= =?UTF-8?q?=20por=20arquivo,=20n=C3=A3o=20por=20grafia?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit O CI do Windows reprovou test_get_weight_path_by_method. A descoberta agora é quem registra best_seg.pt/best_oi.pt (antes as settings do teste é que registravam, com a string exata), e o caminho passa a vir da listagem do diretório: no runner o tempfile devolve o nome longo do usuário enquanto a listagem devolve a forma curta 8.3 (RUNNER~1). Mesmo arquivo, duas grafias — a asserção estava fixando ortografia de caminho, não identidade. Compara resolvidos dos dois lados. Não é defeito de produção: nada lá compara o path armazenado como string, `validate_weight_files` usa os.path.exists e `_maybe_relocate_path` já resolve antes de comparar. Os testes de dual_init escapam porque neles as settings registram os pesos primeiro e a descoberta pula o que já está no catálogo; só o caso que zera as settings dependia da descoberta. Guard novo em test_weight_manager_discovery.py fixa o contrato de identidade. Co-Authored-By: Claude Opus 5 --- tests/core/test_weight_manager_discovery.py | 17 +++++++++++++++++ tests/core/test_weight_type_classification.py | 15 ++++++++++++--- 2 files changed, 29 insertions(+), 3 deletions(-) diff --git a/tests/core/test_weight_manager_discovery.py b/tests/core/test_weight_manager_discovery.py index f81a6503..0f9edaab 100644 --- a/tests/core/test_weight_manager_discovery.py +++ b/tests/core/test_weight_manager_discovery.py @@ -15,6 +15,7 @@ import os import tempfile +from pathlib import Path from unittest.mock import Mock import pytest @@ -113,6 +114,22 @@ def test_discovery_counts_only_new_files(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: diff --git a/tests/core/test_weight_type_classification.py b/tests/core/test_weight_type_classification.py index ef8329cc..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 @@ -132,12 +133,20 @@ def test_get_weight_path_by_method(): 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")