From aee1e6751d5774f9358546ea62e060a8934b83b6 Mon Sep 17 00:00:00 2001 From: lab_clockj Date: Tue, 13 Oct 2026 17:54:20 -0400 Subject: [PATCH 1/2] update gui: support different settings for different cams --- .gitignore | 6 + .../curved_calibration/cylinder_wand_gui.py | 3 +- .../curved_calibration/plate_wand_gui.py | 3 +- .../camera_calibration/results_page/page.py | 64 +++- modules/camera_calibration/view.py | 346 +++++++++++++++++- .../WAND_CALIBRATION_USER_GUIDE.html | 76 +++- .../WAND_CALIBRATION_USER_GUIDE.md | 17 +- .../wand_calibration/wand_calibrator.py | 104 +++++- tests/cylinder_wand/README.md | 33 +- tests/plate_wand/README.md | 14 +- .../2026-10-13-plan-record.md | 112 ++++++ .../wand_per_camera_detect/2026-10-13-plan.md | 125 +++++++ .../2026-10-13-report-record.md | 124 +++++++ .../2026-10-13-report.md | 128 +++++++ .../wand_per_camera_detect/k_per_camera.json | 89 +++++ tests/wand_per_camera_detect/k_per_camera.py | 303 +++++++++++++++ .../k_per_camera_console.txt | 69 ++++ .../k_per_camera_out.txt | 28 ++ .../k_per_camera_panel_ellipse.png | Bin 0 -> 37255 bytes .../k_per_camera_panel_reliable.png | Bin 0 -> 33429 bytes tests/wand_per_camera_detect/r_record.json | 149 ++++++++ tests/wand_per_camera_detect/r_record.py | 340 +++++++++++++++++ .../r_record_console.txt | 86 +++++ tests/wand_per_camera_detect/r_record_out.txt | 33 ++ .../r_record_page_settings.png | Bin 0 -> 114113 bytes 25 files changed, 2205 insertions(+), 47 deletions(-) create mode 100644 tests/wand_per_camera_detect/2026-10-13-plan-record.md create mode 100644 tests/wand_per_camera_detect/2026-10-13-plan.md create mode 100644 tests/wand_per_camera_detect/2026-10-13-report-record.md create mode 100644 tests/wand_per_camera_detect/2026-10-13-report.md create mode 100644 tests/wand_per_camera_detect/k_per_camera.json create mode 100644 tests/wand_per_camera_detect/k_per_camera.py create mode 100644 tests/wand_per_camera_detect/k_per_camera_console.txt create mode 100644 tests/wand_per_camera_detect/k_per_camera_out.txt create mode 100644 tests/wand_per_camera_detect/k_per_camera_panel_ellipse.png create mode 100644 tests/wand_per_camera_detect/k_per_camera_panel_reliable.png create mode 100644 tests/wand_per_camera_detect/r_record.json create mode 100644 tests/wand_per_camera_detect/r_record.py create mode 100644 tests/wand_per_camera_detect/r_record_console.txt create mode 100644 tests/wand_per_camera_detect/r_record_out.txt create mode 100644 tests/wand_per_camera_detect/r_record_page_settings.png 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/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.