diff --git a/.agent/ARCHITECTURE.md b/.agent/ARCHITECTURE.md index e4e829b..566c36f 100644 --- a/.agent/ARCHITECTURE.md +++ b/.agent/ARCHITECTURE.md @@ -125,7 +125,10 @@ Analysis response shape: samples-per-cycle + reliability flag + profile array). - `/api/usaf/analyze` → native JSON: channel × line measurements grid + per-channel detection limit + base64 channel thumbnails (no PNG - plots — frontend draws native charts). + plots — frontend draws native charts). Each line may include optional + `manual_points_by_channel` overrides keyed by channel name; the server + applies a manual 3-bar/2-gap profile calibration only to the matching + requested channel. - `/api/fpn/compute` → small summary stats for live-drag ROI updates (extended in `fpn-rewrite-v1` with `mean_signal`, row/col-only DSNU, row/col peak frequencies, hot/cold counts, drift order). diff --git a/.agent/CHANGELOG_AGENT.md b/.agent/CHANGELOG_AGENT.md index 832c435..cd67b5b 100644 --- a/.agent/CHANGELOG_AGENT.md +++ b/.agent/CHANGELOG_AGENT.md @@ -97,6 +97,12 @@ with `?? null`). --- +## 2026-04-29 — usaf-channel-manual-points-v1 closed (Codex, GPT-5) + +USAF manual profile extrema are now channel-scoped end to end: the picker stores `manualPointsByChannel`, Profile Preview re-measures the active display channel with its own saved 3-bar/2-gap points, saved configs preserve the map, and `/api/usaf/analyze` accepts `manual_points_by_channel` and applies overrides only to the matching analysis channel. Added a FastAPI regression for distinct HG-G/LG-G manual indices, documented the API contract, restarted the local webview server at `http://127.0.0.1:8765/`, and kept the original-code backup at `/Users/mini-09/BioSensorsLab/MantisAnalysis_backup_usaf_manual_points_20260429_000626`. Files: `mantisanalysis/server.py`, `web/src/usaf.tsx`, `tests/unit/test_usaf_manual_points_api.py`, `.agent/ARCHITECTURE.md`, `.agent/runs/usaf-channel-manual-points-v1/`. Smoke: Tier 0–3 PASS; pytest 306 passed / 4 skipped; Vite build PASS. Status: closed, pending optional manual browser walkthrough. + +--- + **2026-04-28 (Night) — play-export-and-roi-fixes-v1 INITIATIVE CLOSED — 7 Play-mode bugs fixed, multi-source job-based export, ROI vertex edit** User reported seven defects in Play mode: (1) ROI vertices were diff --git a/.agent/DECISIONS.md b/.agent/DECISIONS.md index ccad7c7..8634d41 100644 --- a/.agent/DECISIONS.md +++ b/.agent/DECISIONS.md @@ -773,3 +773,33 @@ the pre-merge Tier-4 gate will not run it. keyboard-only control on a projector) surface concrete a11y issues, re-introduce a targeted axe smoke for the affected surface — not a blanket gate. + + +## D-0019 — USAF manual extrema are channel-local profile indices (2026-04-29) + +**Status**: Active. + +**Context**: A user found that manually corrected USAF Profile Preview +extrema could become wrong when switching Display channel and then +running multi-channel analysis. The old picker state treated manual +5-point extrema as line-level data, but the indices refer to sample +positions in a 1-D profile extracted from one concrete channel image. + +**Decision**: Store manual extrema as `line.manualPointsByChannel[ch]` +in the React picker and send them to `/api/usaf/analyze` as +`manual_points_by_channel`. The FastAPI route looks up overrides by +the requested analysis channel and passes them to `measure_line` only +for that channel. Legacy unscoped manual bars/gaps remain a +display-preview fallback only; they are not fanned out across a +multi-channel analysis request. + +**Consequences**: +- Users can calibrate `LG-R`, `LG-G`, `LG-B`, `LG-NIR`, and `LG-Y` + separately and run them together without cross-channel point reuse. +- Saved USAF configs now preserve `manualPointsByChannel`. +- Cross-channel physical registration remains out of scope; this + decision only scopes profile sample indices correctly. + +**Revisit**: If the lab later wants one calibrated channel to seed +another channel, add an explicit "copy extrema to selected channels" +command rather than implicit reuse. diff --git a/.agent/HANDOFF.md b/.agent/HANDOFF.md index 99e9309..88476bd 100644 --- a/.agent/HANDOFF.md +++ b/.agent/HANDOFF.md @@ -1,47 +1,70 @@ # HANDOFF — current live state pointer -Last updated: **2026-05-07** — the `play-lod-ratio-tools-v1` -initiative addressing 6 user-requested Play-mode data-inspection -features (live cursor pixel readback; TBR ergonomics rework; -LoD/Ratio dual-mode rename with k·σ LoD calculation; flexible -labels + numericValue + unit; reorder; detachable FloatingWindow -panel). Seven milestones (M0 scaffold → M1 pixel-readback → M2 -mode rename + migration → M3 table ergonomics → M4 FloatingWindow -pop-out → M5 LoD analysis modal → M6 reviewer pass + close). -3 reviewers spawned at M6 (fastapi-backend-reviewer, -frontend-react-engineer, risk-skeptic). risk-skeptic flagged 2 P0s -(degenerate-baseline collapses threshold to μ; cross-channel -baseline+signal silently produces meaningless number) — both -fixed inline before close, with diagnostic banners surfaced in -the LoD modal + a panel-level commit guard. All P1s resolved. -Initiative artifacts at +Last updated: **2026-05-15** — `usaf-channel-manual-points-v1` +merged (PR #3). USAF manual 5-point extrema are now channel-scoped +end to end: the picker stores `manualPointsByChannel`, Profile +Preview re-measures the active display channel with that channel's +saved 3-bar/2-gap points, and `/api/usaf/analyze` applies +`manual_points_by_channel[ch]` only to the matching analysis +channel. Initiative artifacts at +[`.agent/runs/usaf-channel-manual-points-v1/`](runs/usaf-channel-manual-points-v1/). +Landed on top of the prior `play-lod-ratio-tools-v1` closure +(2026-05-07): 6 Play-mode data-inspection features (live cursor +pixel readback; TBR ergonomics rework; LoD/Ratio dual-mode rename +with k·σ LoD calculation; flexible labels + numericValue + unit; +reorder; detachable FloatingWindow panel), 3 reviewers green at M6, +2 P0s caught and fixed inline. Artifacts at [`.agent/runs/play-lod-ratio-tools-v1/`](runs/play-lod-ratio-tools-v1/). Prior `play-export-and-roi-fixes-v1` initiative artifacts at [`.agent/runs/play-export-and-roi-fixes-v1/`](runs/play-export-and-roi-fixes-v1/). Prior PM-session ultra-review work (27-item Phase-A/B/C sweep, plan at `/Users/zz4/.claude/plans/tranquil-growing-lollipop.md`) -was committed in `8a1e056` / `b01d8f7` / `d1c0a9b` before this +was committed in `8a1e056` / `b01d8f7` / `d1c0a9b` before that session began. ## Current state of the working tree -- Branch: `main` (50 commits ahead of `origin/main`, still never - pushed — B-0010 still open). Most-recent commit is the B-0037 - Phase 2-4 module extractions (sourceModes, RoiOverlay, - WarningCenter, SmallModals). -- **Uncommitted: play-export-and-roi-fixes-v1 closed but unpushed.** - All 7 user-reported Play-mode bugs fixed; 3 reviewers green; all - P0/P1 resolved + verified. Touches: - * `mantisanalysis/server.py` (overlay labels, export_video CRF + - ISP, MultiSourceVideoRequest with Field/Literal validation, 4 - new `/api/play/exports/*` routes, JOBS shutdown hook). - * `mantisanalysis/export_jobs.py` (NEW — JobStore + ExportJob). - * `web/src/playback.tsx` (vertex drag/delete/insert, TBR overlay - channel picker + skip-gain, multi-source export polling + - progress UI, Spinbox decoupled validation, hi-res defaults). - * `tests/unit/test_export_jobs.py` (NEW — 11 tests). - * `.agent/runs/play-export-and-roi-fixes-v1/` (NEW — initiative - + reviews/). +- Branch: `codex/usaf-channel-manual-points`. +- Uncommitted bugfix files: + * `mantisanalysis/server.py` — `ManualUSAFPointsIn`, + `LineSpecIn.manual_points_by_channel`, and per-channel override + lookup in `/api/usaf/analyze`. + * `web/src/usaf.tsx` — `manualPointsByChannel` state/config, + per-display-channel preview remeasurement, and analysis payload + emission only for matching analysis channels. + * `tests/unit/test_usaf_manual_points_api.py` — regression proving + HG-G and LG-G can use different manual profile indices in the same + analysis request. + * `.agent/ARCHITECTURE.md` and + `.agent/runs/usaf-channel-manual-points-v1/` — contract/status + documentation. +- Pre-existing untracked files still present and intentionally not + touched: `.agents/`, `START_MANTIS_WEBVIEW.md`. +- Backup of original code before this fix: + `/Users/mini-09/BioSensorsLab/MantisAnalysis_backup_usaf_manual_points_20260429_000626`. +- Local server is running on `http://127.0.0.1:8765/` from + `.venv/bin/python -m mantisanalysis --no-browser --port 8765`; + refresh the in-app browser to load the new backend/frontend. + +## Smoke status, last verified 2026-04-29 + +- `.venv/bin/python scripts/smoke_test.py --tier 0` — PASS +- `.venv/bin/python scripts/smoke_test.py --tier 1` — PASS +- `.venv/bin/python scripts/smoke_test.py --tier 2` — PASS +- `.venv/bin/python scripts/smoke_test.py --tier 3` — PASS +- `.venv/bin/python -m pytest -q` — PASS, 306 passed / 4 skipped +- `PATH="/opt/homebrew/opt/node@24/bin:$PATH" npm run build` — PASS +- Live server `/api/health` and `/api/usaf/analyze` curl checks — PASS +- Browser screenshots/manual UI walkthrough deferred: Playwright is + not installed and Browser Use tooling was unavailable. + +## Where to pick up next + +1. Refresh `http://127.0.0.1:8765/` and manually try: calibrate + `LG-R`, switch to `LG-G`, calibrate separately, switch back and + confirm Profile Preview keeps each channel's saved extrema. +2. If manual UI behavior looks good, commit branch + `codex/usaf-channel-manual-points`. - The **prior** "Three layered changes" listed below were **all committed** before this session began — see commits `8a1e056` (polish-sweep), `b01d8f7` (B-0037/B-0040/B-0041/B-0042), and diff --git a/.agent/runs/usaf-channel-manual-points-v1/ExecPlan.md b/.agent/runs/usaf-channel-manual-points-v1/ExecPlan.md new file mode 100644 index 0000000..2a3a5e1 --- /dev/null +++ b/.agent/runs/usaf-channel-manual-points-v1/ExecPlan.md @@ -0,0 +1,103 @@ +# ExecPlan — usaf-channel-manual-points-v1 + +Opened: 2026-04-29 +Branch: `codex/usaf-channel-manual-points` +Owner: agent (per user bug report) + +## 1. Goal + +Make USAF manual 5-point calibration channel-specific so each analysis channel uses its own saved bar/gap positions. + +## 2. Why (user value) + +The current USAF picker stores manual extrema on a line without channel identity. The analysis modal then re-runs server auto-detection and ignores the user's manually corrected points, so per-channel resolution results can disagree with the picker preview. + +## 3. Scope (in) + +- `web/src/usaf.tsx`: store manual points as line × channel state, preserve them in config JSON, and send them to analysis. +- `mantisanalysis/server.py`: accept per-channel manual point overrides in `/api/usaf/analyze`. +- Targeted regression test covering channel-specific overrides. +- Minimal docs/status notes for the changed analysis contract. + +## 4. Out of scope (deliberately deferred) + +- Redesigning the analysis modal UI — this fix keeps the existing modal but feeds it correct measurements. +- Solving cross-channel physical registration — manual extrema remain per-channel sample indices, as requested. +- Changing channel key names or GSense extraction constants — forbidden project invariants. + +## 5. Architecture impact + +Touches the FastAPI adapter and React USAF mode only. Analysis math stays in `usaf_groups.py` and remains pure NumPy/SciPy. + +## 6. UI/UX impact + +Users can display `LG-R`, adjust extrema, switch to `LG-G`, adjust separately, and run multi-channel analysis; each channel uses its own saved points when available. + +## 7. Backend / API impact + +`LineSpecIn` gains optional `manual_points_by_channel` data for `/api/usaf/analyze`. Existing payloads without this field remain valid and continue to auto-detect. + +## 8. Data model impact + +USAF picker line objects gain `manualPointsByChannel`, keyed by channel name. Saved config JSON includes this map when present. No H5/session/channel schema changes. + +## 9. Test strategy + +- Unit/API: add targeted FastAPI TestClient regression for `/api/usaf/analyze`. +- Smoke: Tier 0, 1, 2, 3. +- Pytest: full suite. +- Browser: rebuild frontend and verify the server serves the built app; full Playwright is unavailable unless the optional browser dependency is installed. + +## 10. Verification agents to invoke (at close) + +- [ ] docs-handoff-curator +- [ ] risk-skeptic + +Note: reviewer subagents are not spawned in this desktop thread unless explicitly requested by the user. + +## 11. Milestones + +- [x] **M1 — Backend contract** — `/api/usaf/analyze` accepts channel-keyed manual points. +- [x] **M2 — Frontend state flow** — USAF picker stores and sends channel-specific manual points. +- [x] **M3 — Regression coverage** — targeted test proves different channels can use different overrides. +- [x] **M4 — Verification and handoff** — smoke, pytest, build, docs/status updated. + +## 12. Acceptance criteria + +- [x] Manual extrema are saved per analysis channel. +- [x] Multi-channel analysis uses the override for the matching channel only. +- [x] Existing configs/payloads without manual points remain valid. +- [x] Tier 0–3 smoke green. +- [x] Full pytest green. +- [x] Frontend build succeeds. +- [x] Docs/status synced. + +## 13. Risks + +| ID | Risk | Severity | Mitigation | +|---|---|---|---| +| W-1 | Accidentally applying one channel's extrema to another channel | High | Payload keyed by channel name; backend lookup per channel. | +| W-2 | Breaking old saved USAF JSON configs | Medium | Keep legacy fields tolerated and optional. | +| W-3 | Analysis modal still confusing if auto-detected vs manual points are not visually labeled | Low | Preserve server echo of final bar/gap indices; document behavior in status. | + +## 14. Rollback plan + +Use the timestamped backup at `/Users/mini-09/BioSensorsLab/MantisAnalysis_backup_usaf_manual_points_20260429_000626`, or switch back to `main` / revert this branch's diff. + +## 15. Decisions + +- 2026-04-29 **decision**: Store manual extrema as profile sample indices keyed by channel name, not by display mode or gain family, because indices are channel-profile-local. + +## 16. Surprises & discoveries + +- 2026-04-29 The picker preview already supports server-side manual point measurement for one channel; the missing link is persistence and `/api/usaf/analyze` payload propagation. + +## 17. Outcomes & retrospective + +Closed 2026-04-29. The fix keeps user calibration tied to the channel whose profile was displayed: `manualPointsByChannel` in React state/config, `manual_points_by_channel` in the analysis API payload, and a per-channel lookup on the FastAPI side before calling `measure_line`. Profile Preview also labels whether the active display channel is using saved manual extrema or auto extrema. + +Automated verification was green: Tier 0–3 smoke, full pytest, targeted API regression, Vite build, and a live-server curl against `/api/usaf/analyze` proving HG-G and LG-G received distinct manual indices. Browser screenshots were deferred because Playwright is not installed and Browser Use tooling was unavailable in this desktop thread. + +## 18. Final verification checklist + +Tracked in `Status.md`. diff --git a/.agent/runs/usaf-channel-manual-points-v1/Status.md b/.agent/runs/usaf-channel-manual-points-v1/Status.md new file mode 100644 index 0000000..700c06c --- /dev/null +++ b/.agent/runs/usaf-channel-manual-points-v1/Status.md @@ -0,0 +1,155 @@ +# Status — usaf-channel-manual-points-v1 + +Opened: 2026-04-29 +Last updated: 2026-04-29 + +## Current branch + +`codex/usaf-channel-manual-points` + +## Active initiative + +`.agent/runs/usaf-channel-manual-points-v1/` + +## Current milestone + +M4 — Verification and handoff + +## Current focus + +Closed bugfix: per-channel manual USAF extrema now persist through preview, saved config, and multi-channel analysis. + +## Progress + +- [x] M1 — Backend contract +- [x] M2 — Frontend state flow +- [x] M3 — Regression coverage +- [x] M4 — Verification and handoff + +## Current hypothesis + +The bug is caused by USAF manual extrema being stored only on the picked line and not sent to `/api/usaf/analyze`; the analysis modal therefore shows server auto-detected extrema. + +Confirmed and fixed. Manual extrema are now stored as `line.manualPointsByChannel[channel]`, preview re-measures the active display channel with that channel's saved points, and `/api/usaf/analyze` applies `manual_points_by_channel[ch]` only to the matching analysis channel. + +## Modified files + +Initial snapshot: + +``` +## main...origin/main +?? .agents/ +?? START_MANTIS_WEBVIEW.md +``` + +Backup created: + +``` +/Users/mini-09/BioSensorsLab/MantisAnalysis_backup_usaf_manual_points_20260429_000626 +``` + +Implementation files: + +- `.agent/ARCHITECTURE.md` +- `.agent/runs/usaf-channel-manual-points-v1/ExecPlan.md` +- `.agent/runs/usaf-channel-manual-points-v1/Status.md` +- `mantisanalysis/server.py` +- `web/src/usaf.tsx` +- `tests/unit/test_usaf_manual_points_api.py` + +## Tests run + +| Date | Command | Result | Wall time | +|---|---|---|---| +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 0` | PASS | ~3.5 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 1` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 2` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 3` | FAIL, missing `httpx` in `.venv` | ~0 s | +| 2026-04-29 | `.venv/bin/python -m pip install -e '.[dev]'` | PASS | ~2 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 3` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python -m pytest -q` | PASS, 305 passed / 4 skipped | 18.15 s | +| 2026-04-29 | `.venv/bin/python -m pytest tests/unit/test_usaf_manual_points_api.py -q` | PASS, 1 passed | 1.03 s | +| 2026-04-29 | `PATH="/opt/homebrew/opt/node@24/bin:$PATH" npm run build` | PASS | 11.79 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 0` | PASS, prettier + eslint + tsc clean | ~1 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 1` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 2` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python scripts/smoke_test.py --tier 3` | PASS | ~1 s | +| 2026-04-29 | `.venv/bin/python -m pytest -q` | PASS, 306 passed / 4 skipped | 17.07 s | +| 2026-04-29 | `curl http://127.0.0.1:8765/api/health` | PASS, `{"ok": true}` | <1 s | +| 2026-04-29 | live `/api/usaf/analyze` curl with different HG-G/LG-G manual points | PASS, response echoed matching per-channel indices | <1 s | + +## Smoke status (last verified 2026-04-29) + +- Tier 0: PASS +- Tier 1: PASS +- Tier 2: PASS +- Tier 3: PASS +- Tier 4 (Playwright): not installed; pytest web-smoke tests skipped +- pytest: PASS, 306 passed / 4 skipped +- Vite build: PASS + +## Browser verification + +- [ ] Screenshots captured +- [ ] Console error-free +- [x] Network/API smoke error-free via `curl` +- [ ] Keyboard walk clean +- [ ] Responsive at 1024 / 1280 / 1920 +- [ ] Light + dark themes verified + +Screenshots: + +- none; the Browser Use tool was unavailable in this session and Playwright is not installed in `.venv`. + +## Reviewer findings + +| ID | Reviewer | Severity | Title | Disposition | +|---|---|---|---|---| +| F-1 | | | | | + +## Open issues (P0 / P1 / P2 / P3) + +- P0: none +- P1: none +- P2: none +- P3: none + +## Blockers + +- none + +## Known checks still required + +- Optional browser screenshot/manual walkthrough after installing `.[web-smoke]` or using the in-app browser manually. + +## Next concrete action + +1. Refresh `http://127.0.0.1:8765/` in the in-app browser and manually exercise LG-R/LG-G calibration if desired. +2. Commit this branch if the manual browser check looks good. + +## Stop / resume notes + +- Current branch: `codex/usaf-channel-manual-points` +- Active milestone: closed through M4 +- Modified files: see Implementation files above +- Local server: running on `http://127.0.0.1:8765/` from `.venv/bin/python -m mantisanalysis --no-browser --port 8765` +- Next concrete action: user refreshes browser and tries per-channel calibration; then commit if accepted +- Decisions this session: channel-keyed manual extrema map +- Reviewer findings still open: none; subagent reviewers not spawned because this desktop thread did not have explicit user permission for delegated agents + +## Decisions this session + +- 2026-04-29 **decision**: Do not reuse one channel's manual extrema across channels; each channel key owns its own 3-bar/2-gap sample indices. +- 2026-04-29 **decision**: Keep legacy unscoped manual fields as display-preview-only fallback, but do not fan them out into multi-channel analysis payloads. + +## Final verification + +- [x] Manual extrema are saved per display/analysis channel. +- [x] Profile Preview uses the active display channel's saved manual extrema before `Run analysis`. +- [x] Multi-channel analysis sends and consumes matching `manual_points_by_channel` entries only. +- [x] Existing payloads without manual points remain valid. +- [x] Tier 0–3 smoke green. +- [x] Full pytest green. +- [x] Frontend build succeeds. +- [x] Docs/status synced. +- [ ] Browser screenshots/manual UI walkthrough — N/A — Playwright is not installed and Browser Use tooling was unavailable. diff --git a/mantisanalysis/server.py b/mantisanalysis/server.py index dff4c41..b9b2df1 100644 --- a/mantisanalysis/server.py +++ b/mantisanalysis/server.py @@ -201,6 +201,17 @@ class LocateFileRequest(BaseModel): max_depth: int = 6 +class ManualUSAFPointsIn(BaseModel): + """Manual 5-point extrema for one channel/profile. + + Indices are sample positions in the extracted 1-D USAF profile. They are + channel-local: a correction made on LG-R should not be reused for LG-G. + """ + model_config = ConfigDict(extra="forbid") + bar_indices: List[int] = Field(min_length=3, max_length=3) + gap_indices: List[int] = Field(min_length=2, max_length=2) + + class LineSpecIn(BaseModel): model_config = ConfigDict(extra="forbid") group: int @@ -208,6 +219,7 @@ class LineSpecIn(BaseModel): direction: str # "H" or "V" p0: Tuple[float, float] p1: Tuple[float, float] + manual_points_by_channel: Dict[str, ManualUSAFPointsIn] = Field(default_factory=dict) class ISPParams(BaseModel): @@ -3795,9 +3807,13 @@ def usaf_analyze(req: USAFAnalyzeRequest): measurements: {channel: [MeasureResponse-like | null, ...]} channel_thumbnails: {channel: "data:image/png;base64,..."} per_channel_detection_limit: {channel: lp_mm | null} + manual_points_by_channel on each line is optional; when present + for a requested channel, those profile sample indices override + that channel's automatic 5-point extrema detection. """ src = _must_get(req.source_id) - specs = [_line_spec(l) for l in req.lines] + line_inputs = list(req.lines) + specs = [_line_spec(l) for l in line_inputs] chs_requested = req.channels or list(src.channels.keys()) # Apply dark subtraction first (no-op if no dark attached), then ISP. channel_images = { @@ -3815,9 +3831,17 @@ def usaf_analyze(req: USAFAnalyzeRequest): for ch, img in channel_images.items(): ms: List[Any] = [] lm_list = [] - for spec in specs: + for line_in, spec in zip(line_inputs, specs): try: - m = measure_line(img, spec, swath_width=8.0, method="five_point") + manual = (line_in.manual_points_by_channel or {}).get(ch) + m = measure_line( + img, + spec, + swath_width=8.0, + method="five_point", + bar_indices=manual.bar_indices if manual else None, + gap_indices=manual.gap_indices if manual else None, + ) ms.append(_measure_to_response(m).model_dump()) lm_list.append(m) except Exception: diff --git a/tests/unit/test_usaf_manual_points_api.py b/tests/unit/test_usaf_manual_points_api.py new file mode 100644 index 0000000..879cec1 --- /dev/null +++ b/tests/unit/test_usaf_manual_points_api.py @@ -0,0 +1,54 @@ +"""Regression tests for channel-scoped USAF manual extrema.""" +from __future__ import annotations + +from fastapi.testclient import TestClient + +from mantisanalysis.server import app +from mantisanalysis.session import STORE + + +def test_usaf_analyze_uses_manual_points_for_matching_channel_only() -> None: + """A manual correction on LG-G must not be reused for LG-R, and vice versa.""" + STORE.clear() + client = TestClient(app) + + src = client.post("/api/sources/load-sample").json() + sid = src["source_id"] + channels = src["channels"] + assert "HG-G" in channels + assert "LG-G" in channels + + hg_manual = {"bar_indices": [8, 28, 48], "gap_indices": [18, 38]} + lg_manual = {"bar_indices": [11, 31, 51], "gap_indices": [21, 41]} + + r = client.post( + "/api/usaf/analyze", + json={ + "source_id": sid, + "channels": ["HG-G", "LG-G"], + "threshold": 0.2, + "lines": [ + { + "group": 2, + "element": 3, + "direction": "H", + "p0": [80, 80], + "p1": [150, 80], + "manual_points_by_channel": { + "HG-G": hg_manual, + "LG-G": lg_manual, + }, + } + ], + }, + ) + + assert r.status_code == 200, r.text + body = r.json() + hg = body["measurements"]["HG-G"][0] + lg = body["measurements"]["LG-G"][0] + assert hg["bar_indices"] == hg_manual["bar_indices"] + assert hg["gap_indices"] == hg_manual["gap_indices"] + assert lg["bar_indices"] == lg_manual["bar_indices"] + assert lg["gap_indices"] == lg_manual["gap_indices"] + diff --git a/web/src/usaf.tsx b/web/src/usaf.tsx index 6b1c5fc..b9b20a4 100644 --- a/web/src/usaf.tsx +++ b/web/src/usaf.tsx @@ -72,6 +72,39 @@ const sCycTag = (s) => // RGB/grayscale sources as well as H5 HG-/LG- sources. const chipId = (c) => (c.includes('-') ? c : c === 'L' ? 'HG-Y' : `HG-${c}`); +const normalizeManualPoints = (raw) => { + if (!raw) return null; + const bars = raw.bars ?? raw.bar_indices; + const gaps = raw.gaps ?? raw.gap_indices; + if (!Array.isArray(bars) || !Array.isArray(gaps)) return null; + if (bars.length !== 3 || gaps.length !== 2) return null; + return { bars: bars.map((v) => Number(v)), gaps: gaps.map((v) => Number(v)) }; +}; + +const getManualPointsForChannel = (line, channel, includeLegacy = false) => { + if (!line || !channel) return null; + const byChannel = line.manualPointsByChannel || {}; + const scoped = normalizeManualPoints(byChannel[channel]); + if (scoped) return scoped; + if (!includeLegacy) return null; + // Backward compatibility for in-memory / saved configs from before manual + // points were channel-scoped. Only the current display channel may use this + // fallback; multi-channel analysis must not fan one legacy correction out to + // unrelated channels. + return normalizeManualPoints({ bars: line.manualBars, gaps: line.manualGaps }); +}; + +const manualPayloadForChannels = (line, channels) => { + const entries = {}; + for (const ch of channels || []) { + const manual = getManualPointsForChannel(line, ch); + if (manual) { + entries[ch] = { bar_indices: manual.bars, gap_indices: manual.gaps }; + } + } + return Object.keys(entries).length ? entries : undefined; +}; + const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFile }) => { const t = useTheme(); const source = useSource(); @@ -401,11 +434,31 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi // Update a single line's manual 5-point override and re-measure. const updateLinePoints = useCallbackU( - (lineId, nextBars, nextGaps) => { + (lineId, channel, nextBars, nextGaps) => { setLines((prev) => - prev.map((l) => - l.id === lineId ? { ...l, manualBars: nextBars, manualGaps: nextGaps, pending: true } : l - ) + prev.map((l) => { + if (l.id !== lineId) return l; + const manualPointsByChannel = { ...(l.manualPointsByChannel || {}) }; + if ( + channel && + Array.isArray(nextBars) && + nextBars.length === 3 && + Array.isArray(nextGaps) && + nextGaps.length === 2 + ) { + manualPointsByChannel[channel] = { bars: nextBars, gaps: nextGaps }; + } else if (channel) { + delete manualPointsByChannel[channel]; + } + return { + ...l, + manualPointsByChannel, + // Drop legacy globals once the channel-scoped model is used. + manualBars: undefined, + manualGaps: undefined, + pending: true, + }; + }) ); const line = lines.find((l) => l.id === lineId); if (!line) return; @@ -458,13 +511,18 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi (async () => { const updated = await Promise.all( lines.map(async (l) => { - const m = await measureOne({ - group: l.group, - element: l.element, - direction: l.direction, - p0: l.p0, - p1: l.p1, - }); + const manual = getManualPointsForChannel(l, activeChannel, true); + const m = await measureOne( + { + group: l.group, + element: l.element, + direction: l.direction, + p0: l.p0, + p1: l.p1, + }, + manual?.bars, + manual?.gaps + ); return m ? { ...l, m, pending: false } : l; }) ); @@ -838,16 +896,17 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi if (!lines.length) return; const updated = await Promise.all( lines.map(async (l) => { + const manual = getManualPointsForChannel(l, activeChannel, true); const m = await measureOne( { group: l.group, element: l.element, direction: l.direction, p0: l.p0, p1: l.p1 }, - l.manualBars, - l.manualGaps + manual?.bars, + manual?.gaps ); return { ...l, m, pending: false }; }) ); setLines(updated); - }, [lines, measureOne]); + }, [lines, measureOne, activeChannel]); const exportConfig = () => { const cfg = { kind: 'mantis-usaf-config', @@ -891,6 +950,7 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi direction: l.direction, p0: l.p0, p1: l.p1, + manualPointsByChannel: l.manualPointsByChannel || {}, })), selectedIds: [...selectedIds], sortCol, @@ -1004,13 +1064,18 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi // Re-measure each line against the current source. const measured = await Promise.all( placeholder.map(async (l) => { - const m = await measureOne({ - group: l.group, - element: l.element, - direction: l.direction, - p0: l.p0, - p1: l.p1, - }); + const manual = getManualPointsForChannel(l, activeChannel, true); + const m = await measureOne( + { + group: l.group, + element: l.element, + direction: l.direction, + p0: l.p0, + p1: l.p1, + }, + manual?.bars, + manual?.gaps + ); return { ...l, m, pending: false }; }) ); @@ -1067,13 +1132,17 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi const body = { source_id: source.source_id, channels: analysisChannels, - lines: lines.map((l) => ({ - group: l.group, - element: l.element, - direction: l.direction, - p0: l.p0, - p1: l.p1, - })), + lines: lines.map((l) => { + const manualPoints = manualPayloadForChannels(l, analysisChannels); + return { + group: l.group, + element: l.element, + direction: l.direction, + p0: l.p0, + p1: l.p1, + ...(manualPoints ? { manual_points_by_channel: manualPoints } : {}), + }; + }), threshold, transform: { rotation, flip_h: flipH, flip_v: flipV }, isp: buildIspPayload(), @@ -1099,17 +1168,19 @@ const USAFMode = ({ onRunAnalysis, onStatusChange, say, onSwitchSource, onOpenFi const profilePreviewBody = ( { if (!selectedLine) return; - updateLinePoints(selectedLine.id, bars, gaps); + updateLinePoints(selectedLine.id, activeChannel, bars, gaps); }} onReset={() => { if (!selectedLine) return; - updateLinePoints(selectedLine.id, null, null); + updateLinePoints(selectedLine.id, activeChannel, null, null); }} /> ); @@ -3148,6 +3219,8 @@ const michelson5pt = (profile, bars, gaps) => { const ProfilePreview = ({ line, + channel, + manualPoints, _method, multiCount, threshold, @@ -3163,7 +3236,7 @@ const ProfilePreview = ({ const [dragOverride, setDragOverride] = React.useState(null); React.useEffect(() => { setDragOverride(null); - }, [line?.id]); + }, [line?.id, channel]); if (!line) { return ( @@ -3200,10 +3273,11 @@ const ProfilePreview = ({ const p10y = yOf(m.profile_p10); const p90y = yOf(m.profile_p90); - // Effective bars/gaps = (drag-in-progress override) ?? (committed manual) ?? (auto-detected). - const bars = (dragOverride?.bars ?? line.manualBars ?? m.bar_indices ?? []).slice(); - const gaps = (dragOverride?.gaps ?? line.manualGaps ?? m.gap_indices ?? []).slice(); - const manual = Boolean(line.manualBars || line.manualGaps || dragOverride); + // Effective bars/gaps = (drag-in-progress override) ?? (committed manual + // for this display channel) ?? (auto-detected for this display channel). + const bars = (dragOverride?.bars ?? manualPoints?.bars ?? m.bar_indices ?? []).slice(); + const gaps = (dragOverride?.gaps ?? manualPoints?.gaps ?? m.gap_indices ?? []).slice(); + const manual = Boolean(manualPoints || dragOverride); // Live client-side 5-point recompute (updates on drag, no server round-trip). const primary5pt = michelson5pt(profile, bars, gaps); @@ -3260,6 +3334,11 @@ const ProfilePreview = ({ {line.direction} )} + {channel && ( +
+ {manual ? `Manual extrema saved for ${channel}` : `Auto extrema for ${channel}`} +
+ )}