diff --git a/.gitignore b/.gitignore index ab89ad3..23b76d7 100644 --- a/.gitignore +++ b/.gitignore @@ -130,6 +130,12 @@ tests/install_scripts/t4_lf/ !tests/wand_report_error_pdf/** tests/wand_report_error_pdf/**/__pycache__/ tests/wand_report_error_pdf/data/ +# the wand tab's detection settings per camera of 13 Oct 2026: tests/wand_per_camera_detect/ (data/ holds the files +# its Process All Frames runs write) +!tests/wand_per_camera_detect/ +!tests/wand_per_camera_detect/** +tests/wand_per_camera_detect/**/__pycache__/ +tests/wand_per_camera_detect/data/ # tracking through the tube (7 Oct 2026): the library tests in tests/cylinder_stb/ and their pytest subset # (data/ and runs/ hold rendered images, local copies and run outputs, regenerable with its scripts) !tests/test_cylinder_stb.py diff --git a/README.md b/README.md index 8f8e9f3..3dbe6f5 100644 --- a/README.md +++ b/README.md @@ -98,7 +98,7 @@ goes through it step by step. The figures are drawn from the two calibrations' s `assets/readme/make_readme_figures.py`. **The method:** S. Zhong, S. Tan, X. Zhang, Y. Li and R. Ni, "A complete forward projection algorithm through -cylindrical and spherical refractive windows" (preprint, 2026). +cylindrical and spherical refractive windows", [arXiv:2610.17413](https://arxiv.org/abs/2610.17413) (2026). --- @@ -316,6 +316,7 @@ If you use **OpenLPT** in your research, please cite our publications: - Tan, S., Salibindla, A., Masuk, A.U.M. and Ni, R., 2020. **Introducing OpenLPT: new method of removing ghost particles and high-concentration particle shadow tracking**. *Experiments in Fluids*, 61(2), p.47. - Tan, S., Zhong, S. and Ni, R., 2023. **3D Lagrangian tracking of polydispersed bubbles at high image densities**. *Experiments in Fluids*, 64(4), p.85. +- Zhong, S., Tan, S., Zhang, X., Li, Y. and Ni, R., 2026. **A complete forward projection algorithm through cylindrical and spherical refractive windows**. *arXiv preprint* [arXiv:2610.17413](https://arxiv.org/abs/2610.17413). ## License diff --git a/cases/stb_20260727_tracer_all/2026-10-10-report.md b/cases/stb_20260727_tracer_all/2026-10-10-report.md index 2dff2b5..1e9dd0e 100644 --- a/cases/stb_20260727_tracer_all/2026-10-10-report.md +++ b/cases/stb_20260727_tracer_all/2026-10-10-report.md @@ -51,8 +51,8 @@ case; use imgFile_mask as the images for tracking." second row. The queue: - `10Hz_r_b_2` with spacing 5: tracked from 07:50 to 23:18 on 11 Oct (15.5 h), delivered at 23:27; - `10Hz_r_b_1` again: tracked from 23:28 to 12:32 (13.1 h), delivered at 12:38 on 12 Oct; - - then the ten dense runs: `15Hz_r_b_1` since 12:32, about 33 h; if the others take as long, until about - 26 Oct (end of section 12). + - then the ten dense runs: `15Hz_r_b_1` tracked from 12:32 on 12 Oct to 22:05 on 13 Oct (33.6 h), delivered at + 22:09; `15Hz_r_b_2` since 22:05. If the others take as long, until about 26 Oct (end of section 12). - **Still open:** - the masks' last frame (12,604) needs your masking tool. Until it exists, tracking stops at frame 12,603; - `Results_bb` is not touched, and is still one frame off from 257 on. @@ -171,7 +171,8 @@ settings anyway (about 3 weeks per run, almost no tracks kept) and only the 10 H `u_quality.json`, `u_tracks.png`, `u_decision.json`, `u_queue.log`, and its console output `u_console*.txt`; - `step_tail.py` → `u_step_tail.txt`; - `config_change_check.py` → `config_change_check.txt`; - - `track_quality.py` on the batch's runs → `batch_quality.txt`. + - `track_quality.py` on the batch's runs → `batch_quality.txt`; + - `batch_run_summary.py` → `batch_run_summary.txt`. - **The smoke runs themselves (large):** `D:\Shijie Zhong\STB_staging\20260727\smoke\`. ## 8. The threshold tests (T1, T2, I5; 10 Oct) @@ -651,3 +652,32 @@ it needs; within 8 mm, 89–97 % have. - **What the quality shows:** the steps are 1.5 times the 10 Hz runs', as the frequencies are. The line-fit residual is the dense runs' level and far below STB's limit (0.05 mm). The neighbour check is the 10 Hz runs' level: it is relative, so it reads higher where the flow is slower. + +**The first dense run, whole (13 Oct, 22:20):** +- **15Hz_r_b_1** was tracked from 12:32 on 12 Oct to 22:05 on 13 Oct (33.6 h) and delivered at 22:09 (137 files, + 24.2 GB in 4 min). Every file arrived on X: with its size. At its last frame it had 13,356 active long tracks. +- **It held steady from start to end** (`tests/batch_run_summary.txt`, from its log; means per frame, saves apart). + The 10 Hz runs did too (same file). + +| frames | frame time | particles found | long tracks at the end | +|---|---|---|---| +| 0–1,999 | 8.22 s | 77,525 | 13,924 | +| 2,000–3,999 | 8.38 s | 77,805 | 14,236 | +| 4,000–5,999 | 8.40 s | 77,996 | 14,034 | +| 6,000–7,999 | 8.42 s | 77,922 | 14,067 | +| 8,000–9,999 | 8.45 s | 77,698 | 13,024 | +| 10,000–11,999 | 8.23 s | 77,735 | 12,906 | +| 12,000–12,603 | 7.98 s | 77,750 | 13,338 | + +- **Where the time went:** STB's own frame times add up to 29.6 h (8.45 s per frame), with its 64 saves (32 s each, + 0.56 h together). The other 4.0 h, 1.1 s per frame, fall outside its frame timer, as in the 10 Hz runs (0.9 s). +- **Quality at the end** (`tests/batch_quality.txt`: frame 12,400, rebuilt from the saves at 12,600 and 12,603) is + the same as at frame 200: + +| 15Hz_r_b_1 at frame | long tracks | step median | line-fit median, p90 | neighbour median, share > 0.5 | +|---|---|---|---|---| +| 200 | 13,474 | 0.146 mm | 0.0088 mm, 0.026 mm | 0.14, 8.8 % | +| 12,400 | 13,362 | 0.144 mm | 0.0091 mm, 0.026 mm | 0.15, 9.5 % | + +- **Next:** `15Hz_r_b_2` has tracked since 22:05. If the nine runs left take 33.6 h each, the queue ends about + midday on 26 Oct. D: has 444 GB free, and nine more runs like this one need about 220 GB. diff --git a/cases/stb_20260727_tracer_all/README.md b/cases/stb_20260727_tracer_all/README.md index 21e3040..40f2bbc 100644 --- a/cases/stb_20260727_tracer_all/README.md +++ b/cases/stb_20260727_tracer_all/README.md @@ -4,12 +4,13 @@ Your request (10 Oct 2026): "use the new calibrated cam file like what we use fo 10Hz to 35Hz. save interval should be set to 200 frames; create a subfolder called Results_tracer inside each test case; use imgFile_mask as the images for tracking." -**Status (12 Oct, 14:15):** +**Status (13 Oct, 22:20):** - **Your choice of 11 Oct (report section 12):** every run links short tracks within 5 voxels instead of 30 (20–32 % more long tracks in the tests), and the dense runs have their own finder thresholds. -- **The queue:** both 10 Hz runs are tracked with spacing 5 and delivered. `chain_batch.py` started `run_all.py` - (pid 6780) at 23:28 on 11 Oct. `15Hz_r_b_1`, the first dense run, has tracked since 12:32 on 12 Oct and ends - about 22:00 on 13 Oct; at about 33 h per run the other nine take until about 26 Oct. +- **The queue:** `chain_batch.py` started `run_all.py` (pid 6780) at 23:28 on 11 Oct. Both 10 Hz runs and + `15Hz_r_b_1`, the first dense run, are tracked with spacing 5 and delivered. `15Hz_r_b_1` took 33.6 h (12:32 on + 12 Oct to 22:05 on 13 Oct, 24.2 GB), steady from start to end; `15Hz_r_b_2` has tracked since 22:05. At that + pace the nine runs left take until about 26 Oct. - **10 Hz runs:** - **On the renumbered images** (frames 0–12,603), with spacing 5, in `\Results_tracer`: `10Hz_r_b_2` (07:50–23:18 on 11 Oct, 60.0 GB) and `10Hz_r_b_1` (23:28–12:32, 36.1 GB). `10Hz_r_b_1`'s spacing-30 run @@ -17,7 +18,7 @@ case; use imgFile_mask as the images for tracking." - **R1:** frames 0–255 repeat the old run exactly, and the drop at frame 256 is gone. - **The run on the old images** is kept as `10Hz_r_b_1\Results_tracer_old_images` on X:. - **Settings check:** the batch's settings reproduce the tested run byte for byte (S1). -- **15–35 Hz runs:** queued (above). Their images are 6–15 times denser, and their particles move 3.3 times faster. +- **15–35 Hz runs:** tracking (above). Their images are 6–15 times denser, and their particles move 3.3 times faster. - **With the 10 Hz settings,** IPR finds about 390,000 mostly ghost particles per frame, 2.5 min per frame, and almost no tracks survive (S2). - **With a higher threshold** (T1, T3; report sections 8–9), they take 25–30 h per run. After 255 frames they keep @@ -58,8 +59,7 @@ Every run is tracked as `10Hz_r_b_1\stb_new_calib\new_cams` was (case `stb_20260 ## What is written where -**In each run's folder on X:** (`X:\Shijie Zhong\Bubble Shear Project\Processed\20260727\Results\\`; so far -for the two 10 Hz runs) +**In each run's folder on X:** (`X:\Shijie Zhong\Bubble Shear Project\Processed\20260727\Results\\`) - `config.json`: the run's tracking settings, as the GUI's Save writes them. The GUI opens it with the project, and can run it again. In `10Hz_r_b_1` it takes over from the old `config.txt`, as after any GUI Save (the old file stays). - `imgFile_mask\cam0..3ImageNames.txt`: the image lists, as the GUI's preprocessing writes them. `10Hz_r_b_1` keeps @@ -83,7 +83,7 @@ run is copied to `Results_tracer` on X:. | `run_all.py` | the batch: every run in turn, delivered to X:; resumes an interrupted run from its last save; `--status` | | `chain_batch.py` | starts the next batch when the running one has ended with every run delivered and every config checked | | `renumber/` | the renumbering of the images on X: (B2): `renumber.py` (`--check`, `--undo`), its logs, checks and summaries; `mock_check.py` (R0) and `rerun_identical.py` (R1) with their results | -| `tests/` | the checks before the batch (I1, I2, I4, S1, S2, B1), the tests of the dense runs (T1–T4, I5), of the copied frame 256 (D1–D3) and the tuning tests (U0–U6), each with its results beside it | +| `tests/` | the checks before the batch (I1, I2, I4, S1, S2, B1), the tests of the dense runs (T1–T4, I5), of the copied frame 256 (D1–D3) and the tuning tests (U0–U6), and the checks of the batch's runs (`batch_run_summary.py`; `track_quality.py` → `batch_quality.txt`), each with its results beside it | ## Running @@ -105,6 +105,8 @@ Use the `refract` environment (`C:\Users\zcloc\.conda\envs\refract\python.exe`) python tests/tune_tests.py run|summary # U0-U6: tuning the tracking (beside the batch) python tests/step_tail.py # U1-U6: the fastest steps against the baselines python tests/config_change_check.py RUN ... # a run's configs on disk against what make_configs.py writes + python tests/track_quality.py RUN_DIR F [--max-save S] # the long tracks' quality at a save frame F + python tests/batch_run_summary.py RUN ... # finished batch runs, whole, from their logs python chain_batch.py --after PID RUN ... # the next batch after the running one ([--dry-run]) After a reboot, `python run_all.py` with all twelve runs resumes the unfinished run from its last save and tracks diff --git a/cases/stb_20260727_tracer_all/tests/batch_quality.txt b/cases/stb_20260727_tracer_all/tests/batch_quality.txt index 4bc7aba..9812522 100644 --- a/cases/stb_20260727_tracer_all/tests/batch_quality.txt +++ b/cases/stb_20260727_tracer_all/tests/batch_quality.txt @@ -1,5 +1,5 @@ -Batch runs: quality of the long tracks at frame 200 (track_quality.py), read while the batch runs, just after a -save (report section 12, end). Measures as in u_quality.txt. +Batch runs: quality of the long tracks (track_quality.py), at frame 200 while a run tracks (read just after a save), +and at frame 12,400 once it is finished (report section 12, end). Measures as in u_quality.txt. 15Hz_r_b_1 (spacing 5, threshold 45), 12 Oct 2026 at 14:08, after its save at frame 600: python tests/track_quality.py "D:\Shijie Zhong\STB_staging\20260727\15Hz_r_b_1\Results_tracer" 200 --max-save 600 @@ -15,3 +15,18 @@ nb_p90 0.4719463589958337 nb_over_half 0.08806902707527521 frame 200 files ['ExitTrack_400.csv', 'LongTrackInactive_400.csv', 'ExitTrack_600.csv', 'LongTrackActive_600.csv', 'LongTrackInactive_600.csv'] + +15Hz_r_b_1 at its end, 13 Oct 2026 at 22:15, after the run finished (rebuilt from the saves at 12,600 and 12,603): + python tests/track_quality.py "D:\Shijie Zhong\STB_staging\20260727\15Hz_r_b_1\Results_tracer" 12400 +tracks 13362 +step_med 0.14382774818163693 +step_p90 0.18245470651587098 +lf_med 0.009092118454036603 +lf_p90 0.025765276179949007 +nb_radius_mm 8.0 +nb_coverage 0.9966322406825325 +nb_med 0.14970904966496307 +nb_p90 0.48560074880169046 +nb_over_half 0.0948411804460464 +frame 12400 +files ['ExitTrack_12600.csv', 'LongTrackInactive_12600.csv', 'ExitTrack_12603.csv', 'LongTrackActive_12603.csv', 'LongTrackInactive_12603.csv'] diff --git a/cases/stb_20260727_tracer_all/tests/batch_run_summary.py b/cases/stb_20260727_tracer_all/tests/batch_run_summary.py new file mode 100644 index 0000000..e24229c --- /dev/null +++ b/cases/stb_20260727_tracer_all/tests/batch_run_summary.py @@ -0,0 +1,72 @@ +"""The batch's finished runs, whole, from their logs (report section 12, end). Read only. + +For each run given that has DONE.json in its staging folder: + - when it was tracked and delivered (batch_status.json, DONE.json); + - its time in all, the sum of STB's own frame times and its saves' (count, median, sum), and the time outside them; + - per 2,000 frames: the mean frame time (saves apart), the particles IPR found, the long tracks promoted, lost + (to the inactive ones) and exited per frame, and the active long tracks at the block's end; + - the active long tracks at the last frame (`la_end`, as run_all.py records it). + + python tests/batch_run_summary.py RUN ... > tests/batch_run_summary.txt +""" +from __future__ import annotations + +import datetime +import json +import os +import statistics +import sys + +HERE = os.path.dirname(os.path.abspath(__file__)) +sys.path.insert(0, os.path.dirname(HERE)) +import case # noqa: E402 +import parse_log # noqa: E402 + +BLOCK = 2000 + + +def read_json(path): + with open(path, encoding="utf-8") as f: + return json.load(f) + + +def summary(cfg, status, run): + sd = case.stage_dir(cfg, run) + done_path = os.path.join(sd, "DONE.json") + if not os.path.exists(done_path): + print(f"{run}: not finished (no DONE.json)\n") + return + done, st = read_json(done_path), status[run] + recs = parse_log.parse(os.path.join(sd, "log.txt")) + n, last = len(recs), recs[-1]["frame"] + tot = sum(r["t_total"] for r in recs) + sv = [r["t_total"] for r in recs if r["saved"]] + wall = st["wall_s"] + print(f"{run}: tracked {st['start']} to {st['end']}, {n} frames (0..{last}); delivered {done['delivered']}, " + f"{done['files']} files, {done['GB']:.1f} GB") + print(f" in all {wall / 3600:.2f} h = {wall / n:.2f} s per frame; STB's frame times {tot / 3600:.2f} h = " + f"{tot / n:.2f} s per frame, with {len(sv)} saves (median {statistics.median(sv):.1f} s, together " + f"{sum(sv) / 3600:.2f} h); outside them {(wall - tot) / n:.2f} s per frame") + print(" frames frame time particles promoted lost exited long tracks") + print(" (no save) found ------ per frame ----- at the end") + for a in range(0, last + 1, BLOCK): + w = [r for r in recs if a <= r["frame"] < a + BLOCK and "la_n" in r] + if not w: + continue + m = lambda k: statistics.mean(r.get(k, 0) for r in w) # noqa: E731 + t = statistics.mean(r["t_total"] for r in w if not r["saved"]) + print(f" {a:5d}-{min(a + BLOCK - 1, last):<5d} {t:6.2f} s {m('ipr_found'):9.0f} {m('la_add'):8.0f} " + f"{m('li_add'):4.0f} {m('ex_add'):6.1f} {w[-1]['la_new']:11d}") + print(f" active long tracks at the last frame: {st['la_end']}\n") + + +def main(): + cfg = case.load_config() + status = read_json(os.path.join(cfg["staging_root"], "batch_status.json")) + print(f"The batch's finished runs, whole ({datetime.datetime.now():%d %b %Y, %H:%M}); report section 12, end.\n") + for run in sys.argv[1:]: + summary(cfg, status, run) + + +if __name__ == "__main__": + main() diff --git a/cases/stb_20260727_tracer_all/tests/batch_run_summary.txt b/cases/stb_20260727_tracer_all/tests/batch_run_summary.txt new file mode 100644 index 0000000..97a0a58 --- /dev/null +++ b/cases/stb_20260727_tracer_all/tests/batch_run_summary.txt @@ -0,0 +1,41 @@ +The batch's finished runs, whole (13 Oct 2026, 22:18); report section 12, end. + +10Hz_r_b_1: tracked 2026-10-11T23:27:52 to 2026-10-12T12:31:42, 12604 frames (0..12603); delivered 2026-10-12T12:38:15, 137 files, 36.1 GB + in all 13.06 h = 3.73 s per frame; STB's frame times 9.94 h = 2.84 s per frame, with 64 saves (median 46.1 s, together 0.81 h); outside them 0.89 s per frame + frames frame time particles promoted lost exited long tracks + (no save) found ------ per frame ----- at the end + 0-1999 2.58 s 11449 269 96 17.2 21472 + 2000-3999 2.62 s 11261 262 100 20.3 21071 + 4000-5999 2.63 s 11104 256 99 19.9 20708 + 6000-7999 2.62 s 11226 257 98 19.5 21377 + 8000-9999 2.62 s 11292 262 100 19.7 20469 + 10000-11999 2.65 s 11208 261 98 19.6 21815 + 12000-12603 2.64 s 11088 257 99 20.0 21525 + active long tracks at the last frame: 21517 + +10Hz_r_b_2: tracked 2026-10-11T07:49:52 to 2026-10-11T23:17:53, 12604 frames (0..12603); delivered 2026-10-11T23:26:45, 137 files, 60.0 GB + in all 15.47 h = 4.42 s per frame; STB's frame times 12.18 h = 3.48 s per frame, with 64 saves (median 92.8 s, together 1.60 h); outside them 0.94 s per frame + frames frame time particles promoted lost exited long tracks + (no save) found ------ per frame ----- at the end + 0-1999 3.00 s 13213 323 94 28.7 35515 + 2000-3999 3.03 s 12787 305 95 32.0 34573 + 4000-5999 3.03 s 12767 300 92 31.4 35150 + 6000-7999 3.04 s 12704 301 93 32.1 35849 + 8000-9999 3.06 s 12827 307 97 32.0 34934 + 10000-11999 3.06 s 12838 303 95 32.2 35597 + 12000-12603 3.06 s 12845 300 95 32.2 35480 + active long tracks at the last frame: 35511 + +15Hz_r_b_1: tracked 2026-10-12T12:31:42 to 2026-10-13T22:04:50, 12604 frames (0..12603); delivered 2026-10-13T22:08:38, 137 files, 24.2 GB + in all 33.55 h = 9.58 s per frame; STB's frame times 29.59 h = 8.45 s per frame, with 64 saves (median 32.1 s, together 0.56 h); outside them 1.13 s per frame + frames frame time particles promoted lost exited long tracks + (no save) found ------ per frame ----- at the end + 0-1999 8.22 s 77525 2615 193 23.1 13924 + 2000-3999 8.38 s 77805 2623 194 23.6 14236 + 4000-5999 8.40 s 77996 2620 192 23.5 14034 + 6000-7999 8.42 s 77922 2628 194 23.8 14067 + 8000-9999 8.45 s 77698 2620 195 24.1 13024 + 10000-11999 8.23 s 77735 2616 194 23.7 12906 + 12000-12603 7.98 s 77750 2627 193 22.9 13338 + active long tracks at the last frame: 13356 + diff --git a/modules/camera_calibration/curved_calibration/cylinder_wand_gui.py b/modules/camera_calibration/curved_calibration/cylinder_wand_gui.py index 8da5f9a..aabfdc8 100644 --- a/modules/camera_calibration/curved_calibration/cylinder_wand_gui.py +++ b/modules/camera_calibration/curved_calibration/cylinder_wand_gui.py @@ -118,6 +118,7 @@ class Job: pixel_mm: Optional[object] = None # pixel size (mm), one number or one per camera: only to print f in mm detect_mode: Optional[str] = None # how the points were found ("ellipse", "fast", "reliable"; None: unknown) source: str = "" # where the points came from (for the report) + detection: Optional[Dict] = None # the points' detection record (wand_calibrator.save_detection_record) @property def tube(self) -> cm.Tube: @@ -137,7 +138,7 @@ def settings(self) -> Dict: "n": [float(v) for v in self.n], "fit_k1": bool(self.fit_k1), "fit_L": bool(self.fit_L), "noise_weights": self.noise_weights, "n_final": int(self.n_final), "refit_from_last_result": self.start is not None, "pixel_mm": self.pixel_mm, - "detect_mode": self.detect_mode, "source": self.source, + "detect_mode": self.detect_mode, "source": self.source, "detection": self.detection, "frame_ids": [int(f) for f in self.frame_ids], "cam_ids": [int(c) for c in self.cam_ids], "sizes": [[int(w), int(h)] for (w, h) in self.sizes], **({"fix_f": [int(c) for c in self.fix_f]} if self.fix_f else {})} diff --git a/modules/camera_calibration/curved_calibration/plate_wand_gui.py b/modules/camera_calibration/curved_calibration/plate_wand_gui.py index 8bdf4e6..a6060de 100644 --- a/modules/camera_calibration/curved_calibration/plate_wand_gui.py +++ b/modules/camera_calibration/curved_calibration/plate_wand_gui.py @@ -69,6 +69,7 @@ class Job: constraints: Optional[Dict] = None # plate_wand.calibrate's constraints (rig indices, mm) start_source: str = "" # where `start` came from: "last result" or a folder hints_source: str = "" # where `hints` came from + detection: Optional[Dict] = None # the points' detection record (wand_calibrator.save_detection_record) @property def names(self) -> List[str]: @@ -93,7 +94,7 @@ def settings(self) -> Dict: "fit_lens": list(self.fit_lens), "constraints": self.constraints, "hints": ({"source": self.hints_source or "given", **self.hints} if self.hints else None), "pixel_mm": self.pixel_mm, - "detect_mode": self.detect_mode, "source": self.source, + "detect_mode": self.detect_mode, "source": self.source, "detection": self.detection, "frame_ids": [int(f) for f in self.frame_ids], "cam_ids": [int(c) for c in self.cam_ids], "sizes": [[int(w), int(h)] for (w, h) in self.sizes]} diff --git a/modules/camera_calibration/results_page/page.py b/modules/camera_calibration/results_page/page.py index fc3603d..5488634 100644 --- a/modules/camera_calibration/results_page/page.py +++ b/modules/camera_calibration/results_page/page.py @@ -86,6 +86,58 @@ def _fmt(v, d, unit=""): return f"{v:,.{d}f}{unit}" +# ------------------------------------------------------------------ how the wand points were detected +WAND_TYPES = {"dark": "Dark on Bright", "bright": "Bright on Dark"} + + +def _num(v) -> str: + """A detection setting as the panel shows it: at most two decimals, no trailing zeros.""" + return f"{float(v):.2f}".rstrip("0").rstrip(".") + + +def _detection_rows(det: Optional[Dict], mode: Optional[str] = None) -> List[List[str]]: + """The settings table's rows on how the wand points were detected: from their detection record (the wand tab's + _detection.json, wand_calibrator.save_detection_record; its last run), else the mode alone.""" + runs = (det or {}).get("runs") or [] + if not runs: + return [["Detection", str(mode or "unknown (points read from a file)")]] + run = runs[-1] + mode = run.get("mode") or mode + cams = run.get("cameras") or [] + if run.get("per_camera"): + how = "each camera's own settings" + elif any(abs(float(c.get("scale", 1.0)) - 1.0) > 1e-12 for c in cams): + how = "shared settings, scaled by each camera's pixel size" + else: + how = "shared settings" + head = f"{mode} mode, {WAND_TYPES.get(run.get('wand_type'), run.get('wand_type') or '?')}; {how}" + if len(runs) > 1: + def key(r): + return (r.get("mode"), r.get("wand_type"), r.get("per_camera"), + [{k: v for k, v in c.items() if k != "images"} for c in r.get("cameras") or []]) + head += (f"; detected in {len(runs)} runs (resumed), " + + ("all with these settings" if all(key(r) == key(run) for r in runs[:-1]) + else "with different settings: the last run's are shown, the record has every run's")) + rows = [["Detection", head]] + for c in cams: + parts = [f"radius {_num(c['min_radius'])}–{_num(c['max_radius'])} px"] + if mode != "ellipse": + parts.append(f"sensitivity {_num(c['sensitivity'])}") + if "open_px" in c: + parts += [f"Rod opening {int(c['open_px'])} px", f"Rod mask {_num(c['rod_mask_px'])} px"] + if abs(float(c.get("scale", 1.0)) - 1.0) > 1e-12: + parts.append(f"× {float(c['scale']):.4g} for its pixel size") + rows.append([f"Detection, cam{c['cam_id']}", "; ".join(parts)]) + for c in cams: + im = c.get("images") or {} + if im: + rows.append([f"Images, cam{c['cam_id']}", f"{im.get('folder', '')}: {int(im.get('count', 0)):,} images, " + f"{im.get('first', '')} to {im.get('last', '')}"]) + if det.get("file"): + rows.append(["Detection record", str(det["file"])]) + return rows + + # ------------------------------------------------------------------ the reprojection errors' distributions def _nice_up(x: float) -> float: """The smallest of 1, 1.2, 1.5, 2, 2.5, 3, 4, 5, 6, 8 x 10^k not below x (x > 0).""" @@ -254,7 +306,7 @@ def report_from_cylinder(result: Dict, data: Dict, folder: str = None) -> Dict: ["Noise weights", str(gui.get("noise_weights", "camera"))], ["Starts carried", str(gui.get("n_final", 2)) + (" (refit from the last result)" if gui.get("refit_from_last_result") else "")], - ["Detection", str(gui.get("detect_mode") or "unknown (points read from a file)")], + *_detection_rows(gui.get("detection"), gui.get("detect_mode")), ["Points from", str(gui.get("source") or "-")], ["Frame", result.get("frame", "")], ["Run time", _fmt(result.get("gui", {}).get("seconds"), 0, " s")]] @@ -436,7 +488,7 @@ def report_from_plate(result: Dict, data: Dict, folder: str = None) -> Dict: if gui.get("refit_from_last_result") else "")], ["Window-normal hints", _hints_text(gui.get("hints"))], ["Constraints", _constraints_text(gui.get("constraints"), result.get("info", {}).get("constraints"))], - ["Detection", str(gui.get("detect_mode") or "unknown (points read from a file)")], + *_detection_rows(gui.get("detection"), gui.get("detect_mode")), ["Points from", str(gui.get("source") or "-")], ["Frame", result.get("frame", "")], ["Run time", _fmt(result.get("gui", {}).get("seconds"), 0, " s")]] @@ -467,11 +519,13 @@ def report_from_plate(result: Dict, data: Dict, folder: str = None) -> Dict: # ------------------------------------------------------------------ the pinhole calibration def report_from_pinhole(final_params: Dict, wand_points: Dict, wand_length: float, per_frame_errors: Dict = None, - folder: str = None, detect_mode: str = None, source: str = "") -> Dict: + folder: str = None, detect_mode: str = None, source: str = "", + detection: Dict = None) -> Dict: """The report of the GUI's pinhole wand calibration (WandCalibrator.final_params: {cam: {R, T, K, dist}}), with the wand points it used ({frame: {cam: [ball A, ball B]}}). The wand ends are triangulated here (linear, from every camera, distortion removed), so the wand length errors are free ones; the residuals are those of these - points.""" + points. detection: the points' detection record (wand_calibrator.save_detection_record), listed in the settings + table.""" import cv2 cids = sorted(int(c) for c in final_params) P = {} @@ -613,7 +667,7 @@ def ends_of(pts): "to lens distortion the model lacks."}, "noise": None, "settings": [["Wand length", f"{float(wand_length):.4f} mm"], - ["Detection", str(detect_mode or "unknown (points read from a file)")], + *_detection_rows(detection, detect_mode), ["Points from", str(source or "-")], ["Wand ends", "triangulated for this page (linear, all cameras, distortion removed)"]], "files": [{"label": "camFile folder", "path": folder}] if folder else [], diff --git a/modules/camera_calibration/view.py b/modules/camera_calibration/view.py index 62e7392..eb8febc 100644 --- a/modules/camera_calibration/view.py +++ b/modules/camera_calibration/view.py @@ -1520,8 +1520,9 @@ def create_wand_tab_v2(self): pixel_header_item.setToolTip( "Sensor pixel size, for cameras whose pixels differ (e.g. Phantom VEO 710L 20 µm, v2640 13.5 µm). " "Set it for every camera: the Radius Range, Rod opening and Rod mask are then in the first camera's " - "pixels, and each other camera's are scaled by its pixel size. The results page prints f in mm with " - "it. Empty: not used.") + "pixels, and each other camera's are scaled by its pixel size (while the settings are shared: with " + "'Different settings for each camera' ticked, each camera's own values are used as they are). The " + "results page prints f in mm with it. Empty: not used.") header = self.wand_cam_table.horizontalHeader() header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) @@ -1640,6 +1641,49 @@ def create_wand_tab_v2(self): det_layout.addWidget(self.ellipse_opts_container) self.detect_mode_ellipse.toggled.connect(self.ellipse_opts_container.setVisible) + # Different settings for each camera: cameras that see the balls at different sizes (another lens, distance, + # binning or crop) get their own values; while the box is ticked the shared settings above are not used + self.wand_percam_check = QCheckBox("Different settings for each camera") + self.wand_percam_check.setStyleSheet("border: none;") + self.wand_percam_check.setToolTip( + "For cameras that see the balls at different sizes (another lens, distance, binning or crop): each camera " + "gets its own Radius Range, Sensitivity, Rod opening and Rod mask, in its own pixels. Ticking fills the " + "rows from the shared settings (times the Pixel column's factor when it is set), so nothing changes until " + "a row is edited; the shared settings are greyed meanwhile. Unticked: the shared settings, as before (the " + "rows are kept).") + det_layout.addWidget(self.wand_percam_check) + self.wand_percam_table = QTableWidget() + self.wand_percam_table.setColumnCount(6) + self.wand_percam_table.setHorizontalHeaderLabels(["Cam", "R min", "R max", "Sens.", "Opening", "Mask"]) + for col, tip in ((1, "The smallest ball radius searched, in this camera's pixels."), + (2, "The largest ball radius searched, in this camera's pixels (at most a quarter of its " + "image's shorter side once its images are loaded)."), + (3, "The circle finder's sensitivity (0.50-1.00): used by reliable, by fast when its own " + "search finds fewer than two balls, and by the axis points' Detect; not by ellipse."), + (4, "Ellipse mode: the Rod opening, in this camera's pixels."), + (5, "Ellipse mode: the Rod mask, in this camera's pixels.")): + item = self.wand_percam_table.horizontalHeaderItem(col) + if item: + item.setToolTip(tip) + percam_header = self.wand_percam_table.horizontalHeader() + percam_header.setSectionResizeMode(0, QHeaderView.ResizeMode.ResizeToContents) + for col, w in ((1, 52), (2, 52), (3, 52), (4, 64), (5, 52)): + percam_header.setSectionResizeMode(col, QHeaderView.ResizeMode.Fixed) + percam_header.resizeSection(col, w) + percam_header.setStretchLastSection(True) + self.wand_percam_table.verticalHeader().setVisible(False) + self.wand_percam_table.setSelectionMode(QTableWidget.SelectionMode.NoSelection) + self.wand_percam_table.setShowGrid(True) + self.wand_percam_table.setStyleSheet("background-color: transparent; border: 1px solid #333;") + self.wand_percam_table.setMinimumHeight(130) + self._percam_edited = False # a row edited by hand: ticking the box again keeps the rows + self._percam_filling = False + self._percam_exact = {} # {(row, column): exact value} of the cells filled, not edited + self._update_wand_percam_table(self.wand_cam_table.rowCount()) + det_layout.addWidget(self.wand_percam_table) + self.wand_percam_check.toggled.connect(self._on_wand_percam_toggled) + self.detect_mode_group.buttonToggled.connect(lambda _b, _on: self._refresh_wand_detect_state()) + # Actions self.btn_detect_single = QPushButton("Test Detect (Current Frame)") self.btn_detect_single.setStyleSheet(btn_style) @@ -1710,7 +1754,10 @@ def create_wand_tab_v2(self): self.axis_custom_container.setVisible(False) det_layout.addWidget(self.axis_custom_container) self._update_axis_cam_table(int(self.wand_num_cams.value())) - + # the axis points' Detect uses the Sensitivity, even in the ellipse mode + self.axis_mode_custom.toggled.connect(lambda _on: self._refresh_wand_detect_state()) + self._refresh_wand_detect_state() + det_layout.addStretch() self.det_scroll.setWidget(det_content) @@ -2127,7 +2174,11 @@ def _load_wand_points_for_calibration(self): self.status_label.setText("Loaded no wand points (raw detections only).") return if success: - self._wand_detect_mode_used = None # not known for points read from a file + # how the points were detected: from their detection record, when the GUI wrote one beside the file + from .wand_calibration.wand_calibrator import load_detection_record + self._wand_detect_record_used = load_detection_record(file_path) + runs = (self._wand_detect_record_used or {}).get("runs") or [{}] + self._wand_detect_mode_used = runs[-1].get("mode") # None: not known (a file without a record) self._wand_points_source = file_path self._restore_deleted_frames(quiet=True) # new points: no frame of the earlier ones stays deleted self.error_table.setRowCount(0) @@ -3586,6 +3637,8 @@ def _update_wand_table(self, count): self._update_refraction_cam_table(count) self._update_axis_cam_table(count) + if hasattr(self, 'wand_percam_table'): + self._update_wand_percam_table(count) # the per-camera detection rows follow too if getattr(self, '_wand_images_all', None): self._align_wand_images() # the cameras that remain, aligned among themselves @@ -3706,6 +3759,11 @@ def _detect_axis_points(self): if total_images == 0: QMessageBox.warning(self, "No Images", "Load axis images first.") return + cam_settings = self._wand_cam_settings() + problem = self._wand_cam_settings_problem(cam_settings) + if problem: + QMessageBox.warning(self, "Detection settings", problem) + return # Show progress dialog progress_dialog = QProgressDialog( @@ -3732,6 +3790,7 @@ def _detect_axis_points(self): sensitivity=sensitivity, detect_mode="reliable", cam_scales=self._wand_cam_scales(), + cam_settings=cam_settings, ) pts = res.get(cam_idx) if pts is None: @@ -4392,6 +4451,25 @@ def _refresh_wand_radius_range_limit(self): self.radius_range.setRange(1, max_r) + # each camera's own radii (the per-camera table): at most a quarter of its own shorter side, once loaded + t = getattr(self, 'wand_percam_table', None) + if t is not None: + self._percam_filling = True # a value clamped here is not an edit + try: + for row in range(t.rowCount()): + lim = 2000.0 + width_spin = self.wand_cam_table.cellWidget(row, 3) + height_spin = self.wand_cam_table.cellWidget(row, 4) + if (getattr(self, 'wand_images', None) or {}).get(row) and width_spin is not None \ + and height_spin is not None: + lim = float(max(1, min(int(width_spin.value()), int(height_spin.value())) // 4)) + for col in (1, 2): + w = t.cellWidget(row, col) + if w is not None: + w.setMaximum(lim) + finally: + self._percam_filling = False + def _wand_pixel_sizes(self): """{camera row: pixel size in um} of the camera table's Pixel column (0: not set).""" out = {} @@ -4432,7 +4510,214 @@ def _get_wand_detect_opts(self, full=False): return None return {"open_px": int(self.ellipse_open_spin.value()), "rod_mask_px": float(self.ellipse_mask_spin.value()), "guard_px": 3.0, "full": bool(full)} - + + # ---- detection settings per camera ("Different settings for each camera") + def _percam_spin(self, kind, value): + """A compact cell of the per-camera table: "r" a radius (px), "sens" the sensitivity, "open" the Rod opening + (px), "mask" the Rod mask (px).""" + if kind == "open": + w = QSpinBox() + w.setRange(0, 401) + else: + w = TrimmedDoubleSpinBox() + if kind == "sens": + w.setRange(0.5, 1.0) + w.setDecimals(2) + w.setSingleStep(0.01) + elif kind == "mask": + w.setRange(0.0, 400.0) + w.setDecimals(2) # (a value scaled by a pixel size keeps its digits) + else: + w.setRange(1.0, 2000.0) + w.setDecimals(2) + w.setValue(value) + w.setStyleSheet("background: #222; color: white; border: none; padding: 0px;") + w.setButtonSymbols(QAbstractSpinBox.ButtonSymbols.NoButtons) + return w + + def _on_percam_edited(self, cell): + """A cell (row, column) edited by hand: the table is the user's now, and that cell's value is the shown one.""" + if not self._percam_filling: + self._percam_edited = True + self._percam_exact.pop(cell, None) + + def _shared_percam_values(self, row): + """[R min, R max, sensitivity, Rod opening, Rod mask] of camera `row` from the shared settings, times its + Pixel factor when the Pixel column is set: what the detection uses for it while the box is unticked.""" + from .wand_calibration.wand_calibrator import camera_detect_settings + min_r, max_r = self.radius_range.value() + opts = {"open_px": int(self.ellipse_open_spin.value()), "rod_mask_px": float(self.ellipse_mask_spin.value())} + mn, mx, sens, op = camera_detect_settings(row, min_r, max_r, float(self.sensitivity_slider.value()), opts, + self._wand_cam_scales(), None) + return [float(mn), float(mx), float(sens), int(op["open_px"]), float(op["rod_mask_px"])] + + def _update_wand_percam_table(self, count): + """The per-camera table's rows: the cameras that remain keep their values, a new camera starts from the + shared settings.""" + t = self.wand_percam_table + old = {} + for r in range(t.rowCount()): + cells = [t.cellWidget(r, k) for k in range(1, 6)] + if all(c is not None for c in cells): + old[r] = [c.value() for c in cells] + t.setRowCount(count) + self._percam_exact = {rk: v for rk, v in self._percam_exact.items() if rk[0] < count} + for r in range(count): + vals = old.get(r) + if vals is None: + vals = self._shared_percam_values(r) + self._percam_exact.update({(r, k): v for k, v in enumerate(vals)}) + item = QTableWidgetItem(f"{r}") + item.setFlags(Qt.ItemFlag.NoItemFlags) + item.setForeground(Qt.GlobalColor.white) + t.setItem(r, 0, item) + for k, kind in enumerate(("r", "r", "sens", "open", "mask")): + w = self._percam_spin(kind, vals[k]) + w.valueChanged.connect(lambda _v, cell=(r, k): self._on_percam_edited(cell)) + t.setCellWidget(r, k + 1, w) + self._refresh_wand_radius_range_limit() + + def _fill_wand_percam_rows(self): + """Every row from the shared settings (_shared_percam_values). The cells show them to 0.01 px; their exact + values (scaled by a pixel size: 40 x 20/13.5 = 59.259... px) are kept for the detection until a cell is + edited, because the circle finders' points can move by tenths of a pixel when a radius bound moves by a + thousandth (tests/wand_per_camera_detect, report).""" + t = self.wand_percam_table + self._percam_filling = True + try: + for r in range(t.rowCount()): + for k, v in enumerate(self._shared_percam_values(r)): + w = t.cellWidget(r, k + 1) + if w is not None: + w.setValue(v) + self._percam_exact[(r, k)] = v + finally: + self._percam_filling = False + + def _on_wand_percam_toggled(self, on): + if on and not self._percam_edited: + self._fill_wand_percam_rows() # nothing changes until a row is edited + self._refresh_wand_detect_state() + + def _refresh_wand_detect_state(self): + """Which detection settings are in use. The shared ones are greyed while each camera has its own. The + Sensitivity (shared slider and column) only where it is used: not by the ellipse mode, but by the custom axis + points' Detect. Opening and Mask only in the ellipse mode.""" + if not hasattr(self, 'wand_percam_table'): + return + per_cam = self.wand_percam_check.isChecked() + ellipse = self._get_wand_detect_mode() == "ellipse" + custom_axis = hasattr(self, 'axis_mode_custom') and self.axis_mode_custom.isChecked() + sens_used = (not ellipse) or custom_axis + self.radius_range.setEnabled(not per_cam) + self.sensitivity_slider.setEnabled(sens_used and not per_cam) + self.ellipse_open_spin.setEnabled(not per_cam) + self.ellipse_mask_spin.setEnabled(not per_cam) + for w in (self.radius_range, self.sensitivity_slider, self.ellipse_open_spin, self.ellipse_mask_spin): + self._dim_unused(w, not w.isEnabled()) + self.wand_percam_table.setVisible(per_cam) + self.wand_percam_table.setColumnHidden(3, not sens_used) + self.wand_percam_table.setColumnHidden(4, not ellipse) + self.wand_percam_table.setColumnHidden(5, not ellipse) + + @staticmethod + def _dim_unused(widget, dim): + """Fade a disabled setting and its form label: the sliders and the styled spin boxes draw no disabled look.""" + from PySide6.QtWidgets import QGraphicsOpacityEffect + parent = widget.parentWidget() + layout = parent.layout() if parent is not None else None + label = layout.labelForField(widget) if isinstance(layout, QFormLayout) else None + for w in (widget, label): + if w is None: + continue + if dim and not isinstance(w.graphicsEffect(), QGraphicsOpacityEffect): + effect = QGraphicsOpacityEffect(w) + effect.setOpacity(0.35) + w.setGraphicsEffect(effect) + elif not dim and w.graphicsEffect() is not None: + w.setGraphicsEffect(None) + + def _wand_cam_settings(self): + """{camera row: its own detection settings} while "Different settings for each camera" is ticked (the + detection then ignores the shared settings and the Pixel factor for it); None otherwise.""" + if not hasattr(self, 'wand_percam_check') or not self.wand_percam_check.isChecked(): + return None + t = self.wand_percam_table + out = {} + for r in range(t.rowCount()): + cells = [t.cellWidget(r, k) for k in range(1, 6)] + if any(c is None for c in cells): + continue + vals = [] + for k, c in enumerate(cells): + v = c.value() + exact = self._percam_exact.get((r, k)) # a filled cell not edited since: its exact value + if exact is not None and abs(v - exact) <= 0.5 * 10.0 ** -(c.decimals() if hasattr(c, "decimals") + else 0) + 1e-12: + v = exact + vals.append(v) + mn, mx, sens, op, mask = vals + out[r] = {"min_radius": float(mn), "max_radius": float(mx), "sensitivity": float(sens), + "open_px": int(op), "rod_mask_px": float(mask)} + return out + + @staticmethod + def _wand_cam_settings_problem(cam_settings): + """Why the per-camera settings cannot run (a radius range with R min not below R max), or None.""" + bad = [r for r, s in sorted((cam_settings or {}).items()) if not s["min_radius"] < s["max_radius"]] + if not bad: + return None + return ("R min must be below R max in the table of each camera's settings: " + + ", ".join(f"camera {r}" for r in bad) + ".") + + def _wand_detection_record(self, resumed=False): + """One run's entry of the detection record (wand_calibrator.save_detection_record): the mode, the Wand Type, + and for each camera with images its settings as its tasks get them (camera_detect_settings: the shared ones, + scaled by the Pixel factor, or its own row) and its images.""" + import time as _time + from .wand_calibration.wand_calibrator import camera_detect_settings + min_r, max_r = self.radius_range.value() + sensitivity = float(self.sensitivity_slider.value()) + opts = self._get_wand_detect_opts() + scales = self._wand_cam_scales() + own = self._wand_cam_settings() + pixels = self._wand_pixel_sizes() + cams = [] + for c in sorted(self.wand_images): + files = self.wand_images.get(c) or [] + if not files: + continue + mn, mx, sens, op = camera_detect_settings(c, min_r, max_r, sensitivity, opts, scales, own) + entry = {"cam_id": int(c), "min_radius": float(mn), "max_radius": float(mx), "sensitivity": float(sens)} + if op is not None: + entry.update({"open_px": int(op["open_px"]), "rod_mask_px": float(op["rod_mask_px"]), + "guard_px": float(op.get("guard_px", 3.0))}) + if not own and scales: + entry["scale"] = float(scales.get(c, 1.0)) + if pixels.get(c): + entry["pixel_um"] = float(pixels[c]) + entry["images"] = {"folder": os.path.dirname(str(files[0])), "first": os.path.basename(str(files[0])), + "last": os.path.basename(str(files[-1])), "count": len(files)} + cams.append(entry) + return {"written": _time.strftime("%Y-%m-%d %H:%M:%S"), "resumed": bool(resumed), + "mode": self._get_wand_detect_mode(), "wand_type": self._wand_type(), "per_camera": bool(own), + "cameras": cams} + + def _copy_detection_record(self, csv_path): + """Beside an exported copy of the points: the detection record of the points in memory, if there is one.""" + rec = getattr(self, '_wand_detect_record_used', None) + if rec: + from .wand_calibration.wand_calibrator import write_detection_record + write_detection_record(csv_path, rec) + + def _describe_cam_settings(self, cam_settings): + """One console line of the per-camera settings (those the current mode uses).""" + ellipse = self._get_wand_detect_mode() == "ellipse" + return "; ".join( + f"cam {r}: r {s['min_radius']:g}-{s['max_radius']:g}" + + (f", opening {s['open_px']}, mask {s['rod_mask_px']:g}" if ellipse else f", sens {s['sensitivity']:g}") + for r, s in sorted(cam_settings.items())) + def populate_wand_table(self): """Populates the frame table with filenames and status.""" # Find max length and reference cam @@ -5407,20 +5692,30 @@ def _detect_single_frame(self, checked=False): if not single_frame_dict: return + cam_settings = self._wand_cam_settings() + problem = self._wand_cam_settings_problem(cam_settings) + if problem: + QMessageBox.warning(self, "Detection settings", problem) + return + self._busy_begin('wand_detect_single', 'Detecting single frame') cam_scales = self._wand_cam_scales() - print( - f"Detecting frame {idx}, mode='{wand_type}', radius=[{min_r},{max_r}], " - f"sensitivity={sensitivity}, detect_mode='{detect_mode}'" - + (", per-camera scales " + ", ".join(f"cam {c + 1}: {s:.4f}" for c, s in sorted(cam_scales.items())) - if cam_scales else "") - ) + if cam_settings: + print(f"Detecting frame {idx}, mode='{wand_type}', detect_mode='{detect_mode}', each camera's own " + f"settings: {self._describe_cam_settings(cam_settings)}") + else: + print( + f"Detecting frame {idx}, mode='{wand_type}', radius=[{min_r},{max_r}], " + f"sensitivity={sensitivity}, detect_mode='{detect_mode}'" + + (", per-camera scales " + ", ".join(f"cam {c + 1}: {s:.4f}" for c, s in sorted(cam_scales.items())) + if cam_scales else "") + ) # Worker Setup self._single_detect_worker = WandDetectionSingleFrameWorker( self.wand_calibrator, single_frame_dict, wand_type, min_r, max_r, sensitivity, detect_mode, - detect_opts=self._get_wand_detect_opts(full=True), cam_scales=cam_scales, + detect_opts=self._get_wand_detect_opts(full=True), cam_scales=cam_scales, cam_settings=cam_settings, ) self._single_detect_worker.finished_signal.connect(self._on_single_detection_finished) @@ -5537,6 +5832,11 @@ def _process_wand_frames(self, checked=False): if count == 0: QMessageBox.warning(self, "No Images", "Please load images first.") return + cam_settings = self._wand_cam_settings() + problem = self._wand_cam_settings_problem(cam_settings) + if problem: + QMessageBox.warning(self, "Detection settings", problem) + return # 0. Info Popup (UX Improvement) QMessageBox.information(self, "Process Frames", @@ -5571,9 +5871,17 @@ def _process_wand_frames(self, checked=False): detect_mode = self._get_wand_detect_mode() cam_scales = self._wand_cam_scales() - if cam_scales: + if cam_settings: + print("Each camera's own detection settings: " + self._describe_cam_settings(cam_settings)) + elif cam_scales: print("Per-camera detection scales (pixel sizes): " + ", ".join(f"cam {c + 1}: {s:.4f}" for c, s in sorted(cam_scales.items()))) + # how these points are detected, beside their file (a resume adds its run): the results page lists it + from .wand_calibration.wand_calibrator import save_detection_record + self._wand_detect_record_used = save_detection_record( + autosave_path, self._wand_detection_record(resumed=resume), resume=resume) + if self._wand_detect_record_used: + print(f"Detection record: {self._wand_detect_record_used['file']}") # Start Worker self._proc_thread = WandDetectionWorker( @@ -5585,6 +5893,7 @@ def _process_wand_frames(self, checked=False): detect_mode=detect_mode, detect_opts=self._get_wand_detect_opts(), cam_scales=cam_scales, + cam_settings=cam_settings, ) # remembered for the cylinder calibration's checks (through a tube only "ellipse" measures the centres) self._wand_detect_mode_used = detect_mode @@ -5640,6 +5949,7 @@ def _on_process_finished(self, success, msg): path, _ = QFileDialog.getSaveFileName(self, "Save Rescue Data", "wand_rescue.csv", "CSV Files (*.csv)") if path: self.wand_calibrator.export_wand_data(path) + self._copy_detection_record(path) else: QMessageBox.warning(self, "Detection Failed", msg) return # a failed detection is not complete: no "Export Data" question @@ -5662,6 +5972,7 @@ def _on_process_finished(self, success, msg): if path: success, msg = self.wand_calibrator.export_wand_data(path) if success: + self._copy_detection_record(path) QMessageBox.information(self, "Export", "Data exported successfully.") else: QMessageBox.warning(self, "Export Failed", msg) @@ -9174,7 +9485,8 @@ def _run_cylinder_wand_calibration(self, wand_len, dist_coeff_num, precalibrate= n_final=6 if self.cyl_thorough_check.isChecked() else 2, start=start, pixel_mm=self._wand_pixel_mm_for(cids, settings), detect_mode=getattr(self, '_wand_detect_mode_used', None), - source=str(getattr(self, '_wand_points_source', "") or "")) + source=str(getattr(self, '_wand_points_source', "") or ""), + detection=getattr(self, '_wand_detect_record_used', None)) job.check() except ValueError as e: QMessageBox.warning(self, "Cylinder Calibration", str(e)) @@ -9451,7 +9763,8 @@ def _run_plate_wand_calibration(self, wand_len, dist_coeff_num, precalibrate=Fal n_final=6 if self.refr_thorough_check.isChecked() else 2, start=start, pixel_mm=self._wand_pixel_mm_for(cids, settings), detect_mode=getattr(self, '_wand_detect_mode_used', None), - source=str(getattr(self, '_wand_points_source', "") or ""), window_ids=used) + source=str(getattr(self, '_wand_points_source', "") or ""), window_ids=used, + detection=getattr(self, '_wand_detect_record_used', None)) job.check() except ValueError as e: QMessageBox.warning(self, "Flat-Window Calibration", str(e)) @@ -9590,7 +9903,8 @@ def _show_pinhole_report(self, output_dir=None): _time.strftime("pinhole_%Y%m%d_%H%M%S")) report = rp.report_from_pinhole(wc.final_params, pts, float(self.wand_len_spin.value()), folder=folder, detect_mode=getattr(self, '_wand_detect_mode_used', None), - source=str(getattr(self, '_wand_points_source', "") or "")) + source=str(getattr(self, '_wand_points_source', "") or ""), + detection=getattr(self, '_wand_detect_record_used', None)) path = rp.write_page(report, os.path.join(folder, "calibration_report.html")) if getattr(self, 'report_tab', None) is not None: self.report_tab.show_page(path) diff --git a/modules/camera_calibration/wand_calibration/WAND_CALIBRATION_USER_GUIDE.html b/modules/camera_calibration/wand_calibration/WAND_CALIBRATION_USER_GUIDE.html index ce8b75c..9d672eb 100644 --- a/modules/camera_calibration/wand_calibration/WAND_CALIBRATION_USER_GUIDE.html +++ b/modules/camera_calibration/wand_calibration/WAND_CALIBRATION_USER_GUIDE.html @@ -372,7 +372,10 @@

3.1 Steps

  • Choose the Wand Type, the Detection Mode (and, for "ellipse", the Rod opening and Rod mask) and the Radius Range (sections 3.2 to 3.4).
  • +href="#34-setting-rod-opening-and-rod-mask-ellipse-mode">3.4). +Cameras that see the balls at different sizes (another lens, distance, +binning or crop) get their own values with Different settings +for each camera.
  • Test on single frames. Click a frame in the Frame List, then Test Detect (Current Frame). Look at every Cam tab: each ball should carry exactly one circle (or ellipse). @@ -387,6 +390,17 @@

    3.1 Steps

  • Choose the file for the results (wand_points.csv). It is also the autosave. Choosing an existing file offers to resume it.
  • +
  • The detection record goes beside it: +wand_points_detection.json. It holds how the points were +found: the mode, the Wand Type, and each camera's settings (as its +detector got them) and images. +
      +
    • A resume adds an entry to it, and Export Data copies it beside the +exported file.
    • +
    • Load Wand Points reads it back, and the results +page lists it in its settings table. For reproduction, keep it with the +points.
    • +
  • The progress dialog has Pause and Stop.