feat(autocal): 添加标定记录总览与测点计划对照
集中显示采集覆盖、加载卸载响应与已确认法向灵敏度,并保存计划和复核快照。通过44项相关测试、已有视频会话检查及代码和文档检查。
This commit is contained in:
@@ -0,0 +1,106 @@
|
||||
"""Coverage and planned-point comparison over the existing review contract."""
|
||||
|
||||
import csv
|
||||
import hashlib
|
||||
import json
|
||||
import math
|
||||
from pathlib import Path
|
||||
|
||||
from .calibration_session import write_json
|
||||
from .session_review import load_review, save_review
|
||||
|
||||
|
||||
def plan_points(plan: dict, definition: dict) -> list[dict]:
|
||||
points = {}
|
||||
origin_x, origin_y = definition["origin_raster_xy_px"]
|
||||
height, width = definition["raster_size_hw"]
|
||||
orders = set()
|
||||
for row in plan["rows"]:
|
||||
if (row["mapping_sha256"] != definition["mapping_sha256"]
|
||||
or row["u_positive"] != definition["u_positive"]
|
||||
or row["v_positive"] != definition["v_positive"]
|
||||
or (int(row["origin_raster_x_px"]), int(row["origin_raster_y_px"])) != (origin_x, origin_y)
|
||||
or (int(row["raster_height_px"]), int(row["raster_width_px"])) != (height, width)):
|
||||
raise ValueError("planned points use a different UV mapping, origin or axes")
|
||||
u, v, order = int(row["u_px"]), int(row["v_px"]), int(row["order"])
|
||||
position = [float(row[f"{axis}_sensor_mm"]) for axis in "xyz"]
|
||||
if (not 0 <= origin_x + u < width or not 0 <= origin_y - v < height
|
||||
or not all(math.isfinite(x) for x in position) or order <= 0 or order in orders):
|
||||
raise ValueError("invalid planned point or duplicate order")
|
||||
orders.add(order)
|
||||
point = points.setdefault((u, v), {"uv_xy_px": [u, v], "position_sensor_mm": position, "orders": []})
|
||||
if point["position_sensor_mm"] != position:
|
||||
raise ValueError("one UV point has inconsistent planned 3D coordinates")
|
||||
point["orders"].append(order)
|
||||
if not points:
|
||||
raise ValueError("the planned-point file contains no points")
|
||||
return sorted(points.values(), key=lambda p: min(p["orders"]))
|
||||
|
||||
|
||||
def read_plan(path: Path, definition: dict) -> dict:
|
||||
with path.open(encoding="utf-8-sig", newline="") as handle:
|
||||
rows = list(csv.DictReader(handle))
|
||||
plan = {"source": str(path), "sha256": hashlib.sha256(path.read_bytes()).hexdigest(), "rows": rows}
|
||||
plan_points(plan, definition)
|
||||
return plan
|
||||
|
||||
|
||||
def overview_rows(review: dict, plan: dict | None = None) -> list[dict]:
|
||||
planned = plan_points(plan, review["uv_definition"]) if plan is not None else []
|
||||
planned_uv = {tuple(p["uv_xy_px"]) for p in planned}
|
||||
actual = {tuple(site["uv_xy_px"]): site for site in review["sites"]}
|
||||
rows = []
|
||||
for point in planned + [s for uv, s in actual.items() if uv not in planned_uv]:
|
||||
site = actual.get(tuple(point["uv_xy_px"]))
|
||||
if site is not None and point.get("orders") and site.get("position_sensor_mm"):
|
||||
if not all(math.isclose(a, b, rel_tol=0, abs_tol=1e-6)
|
||||
for a, b in zip(point["position_sensor_mm"], site["position_sensor_mm"])):
|
||||
raise ValueError("planned and recorded 3D coordinates do not match")
|
||||
trials = [t for t in review["trials"] if site is not None and t.get("site_id") == site["site_id"]]
|
||||
complete = [t for t in trials if t["status"] == "complete"]
|
||||
windows = site["windows"] if site else []
|
||||
confirmed = [w for w in windows if w["stability_verified"] and w["status"] == "candidate"]
|
||||
all_reviewed = bool(complete) and all(
|
||||
sum(w["trial_id"] == t["trial_id"] for w in confirmed)
|
||||
== len(t.get("plan", {}).get("steps", [w for w in windows if w["trial_id"] == t["trial_id"]]))
|
||||
and any(w["trial_id"] == t["trial_id"] for w in confirmed) for t in complete)
|
||||
state = ("uncollected" if not trials else "incomplete" if len(complete) != len(trials)
|
||||
else "reviewed" if all_reviewed else "pending")
|
||||
fit = (site["confirmed_fit"] or site["fit"]) if site else None
|
||||
training = [w for w in windows if w["branch"] in {"zero", "load"} and w["status"] == "candidate"
|
||||
and (not site["confirmed_fit"] or w["stability_verified"])] if site else []
|
||||
rows.append({
|
||||
"site_id": site["site_id"] if site else None, "uv_xy_px": point["uv_xy_px"],
|
||||
"position_sensor_mm": site.get("position_sensor_mm") if site else point["position_sensor_mm"],
|
||||
"orders": point.get("orders", []), "planned": bool(point.get("orders")),
|
||||
"status": state, "trials": len(trials), "complete_trials": len(complete),
|
||||
"confirmed_windows": len(confirmed), "windows": len(windows),
|
||||
"confirmed_levels_n": sorted({w["reference_n"] for w in confirmed if w["branch"] in {"zero", "load"}}),
|
||||
"fit": fit, "fit_basis": ("confirmed" if site["confirmed_fit"] else "candidate") if fit else "none",
|
||||
"fit_trials": len({w["trial_id"] for w in training}) if fit else 0,
|
||||
"fit_range_n": [min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)] if fit else None,
|
||||
})
|
||||
return rows
|
||||
|
||||
|
||||
def save_overview(review: dict, plan: dict | None) -> Path:
|
||||
rows = overview_rows(review, plan)
|
||||
path = save_review(review)
|
||||
write_json(path.parent / "overview.json", {
|
||||
"format_version": "autocal-overview-1.0", "session_id": review["session_id"],
|
||||
"plan": plan, "sites": rows,
|
||||
})
|
||||
return path
|
||||
|
||||
|
||||
def load_overview(directory: Path, path: Path) -> tuple[dict, dict | None]:
|
||||
review = load_review(directory, path)
|
||||
overview_path = path.parent / "overview.json"
|
||||
plan = None
|
||||
if overview_path.is_file():
|
||||
saved = json.loads(overview_path.read_text(encoding="utf-8"))
|
||||
if saved["format_version"] != "autocal-overview-1.0" or saved["session_id"] != review["session_id"]:
|
||||
raise ValueError("overview identity mismatch")
|
||||
plan = saved["plan"]
|
||||
overview_rows(review, plan)
|
||||
return review, plan
|
||||
@@ -223,6 +223,7 @@ def review_session(directory: Path, *, selections: dict | None = None) -> dict:
|
||||
"source_directory": str(root), "session_status": manifest["status"],
|
||||
"status": "exploratory", "sites": sites, "issues": issues,
|
||||
"selections": selections, "input_sha256": inputs, "analysis_inputs": analysis_inputs,
|
||||
"trials": manifest["trials"], "uv_definition": manifest["uv_definition"],
|
||||
}
|
||||
|
||||
|
||||
|
||||
@@ -351,7 +351,7 @@ class CalibrationPanel(QWidget):
|
||||
self._sync()
|
||||
|
||||
def _review(self) -> None:
|
||||
from .session_review_dialog import SessionReviewDialog
|
||||
from .record_overview_dialog import RecordOverviewDialog
|
||||
|
||||
session = self.trial.session
|
||||
directory = QFileDialog.getExistingDirectory(
|
||||
@@ -360,7 +360,7 @@ class CalibrationPanel(QWidget):
|
||||
)
|
||||
if directory:
|
||||
try:
|
||||
dialog = SessionReviewDialog(Path(directory), self)
|
||||
dialog = RecordOverviewDialog(Path(directory), self)
|
||||
dialog.exec()
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.result_label.setText(t("calibration.error").format(detail=exc))
|
||||
|
||||
@@ -0,0 +1,278 @@
|
||||
"""Recorded-site coverage and normal response, with optional operator CSV plan."""
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from orisys_app_shared.i18n import t
|
||||
from PySide6.QtCharts import QChart, QChartView, QLineSeries, QScatterSeries, QValueAxis
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtGui import QColor, QPainter
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QDialog, QFileDialog, QHBoxLayout, QHeaderView, QLabel,
|
||||
QPushButton, QTableWidget, QTableWidgetItem, QVBoxLayout,
|
||||
)
|
||||
|
||||
from ..record_overview import load_overview, overview_rows, read_plan, save_overview
|
||||
from ..session_review import review_session
|
||||
from .session_review_dialog import SessionReviewDialog
|
||||
|
||||
|
||||
class RecordOverviewDialog(QDialog):
|
||||
def __init__(self, directory: Path, parent=None):
|
||||
super().__init__(parent)
|
||||
self.directory = directory
|
||||
self.review = review_session(directory)
|
||||
self.plan = None
|
||||
self.rows = []
|
||||
self.setWindowTitle(t("overview.title"))
|
||||
self.resize(1240, 850)
|
||||
layout = QVBoxLayout(self)
|
||||
self.notice = QLabel(t("overview.notice"), self)
|
||||
self.notice.setWordWrap(True)
|
||||
layout.addWidget(self.notice)
|
||||
toolbar = QHBoxLayout()
|
||||
self.map_mode = QComboBox(self)
|
||||
self.map_mode.addItems([t("overview.coverage"), t("overview.sensitivity")])
|
||||
toolbar.addWidget(self.map_mode)
|
||||
for name, slot in (("import_plan", self._import_plan), ("clear_plan", self._clear_plan),
|
||||
("load", self._load), ("save", self._save)):
|
||||
button = QPushButton(t("overview." + name), self)
|
||||
button.setObjectName("overview_" + name)
|
||||
button.clicked.connect(slot)
|
||||
toolbar.addWidget(button)
|
||||
layout.addLayout(toolbar)
|
||||
self.summary = QLabel(self)
|
||||
self.summary.setWordWrap(True)
|
||||
layout.addWidget(self.summary)
|
||||
charts = QHBoxLayout()
|
||||
self.uv_chart, self.response_chart = QChart(), QChart()
|
||||
self.uv_view, self.response_view = QChartView(self.uv_chart), QChartView(self.response_chart)
|
||||
for view in (self.uv_view, self.response_view):
|
||||
view.setRenderHint(QPainter.RenderHint.Antialiasing)
|
||||
charts.addWidget(self.uv_view, 1)
|
||||
charts.addWidget(self.response_view, 2)
|
||||
layout.addLayout(charts, 2)
|
||||
self.table = QTableWidget(0, 11, self)
|
||||
self.table.setObjectName("overviewSites")
|
||||
self.table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
|
||||
self.table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
|
||||
self.table.setSelectionMode(QTableWidget.SelectionMode.SingleSelection)
|
||||
self.table.setHorizontalHeaderLabels([t("overview.col_" + key) for key in (
|
||||
"order", "site", "uv", "xyz", "status", "trials", "windows", "levels", "sensitivity", "range", "fit_trials")])
|
||||
self.table.horizontalHeader().setSectionResizeMode(QHeaderView.ResizeMode.ResizeToContents)
|
||||
self.table.horizontalHeader().setStretchLastSection(True)
|
||||
layout.addWidget(self.table, 1)
|
||||
selection = QHBoxLayout()
|
||||
self.trials = QComboBox(self)
|
||||
self.trials.setObjectName("overviewTrial")
|
||||
self.details_button = QPushButton(t("overview.details"), self)
|
||||
selection.addWidget(QLabel(t("overview.trial_filter"), self))
|
||||
selection.addWidget(self.trials, 1)
|
||||
selection.addWidget(self.details_button)
|
||||
layout.addLayout(selection)
|
||||
self.selected_summary = QLabel(self)
|
||||
self.selected_summary.setWordWrap(True)
|
||||
layout.addWidget(self.selected_summary)
|
||||
self.message = QLabel(self)
|
||||
self.message.setWordWrap(True)
|
||||
layout.addWidget(self.message)
|
||||
self.table.itemSelectionChanged.connect(self._select_site)
|
||||
self.map_mode.currentIndexChanged.connect(self._show_uv)
|
||||
self.trials.currentIndexChanged.connect(self._show_response)
|
||||
self.details_button.clicked.connect(self._details)
|
||||
self._refresh()
|
||||
|
||||
def _refresh(self):
|
||||
selected = self.table.currentRow()
|
||||
self.rows = overview_rows(self.review, self.plan)
|
||||
self.table.blockSignals(True)
|
||||
self.table.setRowCount(len(self.rows))
|
||||
for index, row in enumerate(self.rows):
|
||||
fit = row["fit"]
|
||||
sensitivity = (f"{fit['sensitivity_raw_per_n']:.6g} · " + t("overview." + row["fit_basis"])) if fit else "—"
|
||||
values = [", ".join(map(str, row["orders"])) or "—", row["site_id"] or "—",
|
||||
", ".join(map(str, row["uv_xy_px"])),
|
||||
", ".join(f"{x:.3f}" for x in row["position_sensor_mm"]) if row["position_sensor_mm"] else "—",
|
||||
t("overview." + row["status"]), f"{row['complete_trials']}/{row['trials']}",
|
||||
f"{row['confirmed_windows']}/{row['windows']}",
|
||||
", ".join(f"{v:g}" for v in row["confirmed_levels_n"]) or "—", sensitivity,
|
||||
"–".join(f"{v:g}" for v in row["fit_range_n"]) if fit else "—", str(row["fit_trials"])]
|
||||
for column, value in enumerate(values):
|
||||
self.table.setItem(index, column, QTableWidgetItem(value))
|
||||
self.table.blockSignals(False)
|
||||
collected = sum(row["trials"] > 0 for row in self.rows)
|
||||
self.summary.setText(t("overview.summary").format(
|
||||
collected=collected, planned=sum(r["planned"] for r in self.rows) if self.plan else "—",
|
||||
missing=sum(r["status"] == "uncollected" for r in self.rows),
|
||||
incomplete=sum(r["status"] == "incomplete" for r in self.rows),
|
||||
pending=sum(r["status"] == "pending" for r in self.rows),
|
||||
reviewed=sum(r["status"] == "reviewed" for r in self.rows),
|
||||
extra=sum(not r["planned"] and r["trials"] > 0 for r in self.rows) if self.plan else "—",
|
||||
) + ("\n" + t("overview.no_plan") if self.plan is None else ""))
|
||||
self.message.setText(t("review.issues").format(count=len(self.review["issues"])))
|
||||
if self.rows:
|
||||
self.table.selectRow(min(max(selected, 0), len(self.rows) - 1))
|
||||
self._select_site()
|
||||
|
||||
def _select_site(self):
|
||||
index = self.table.currentRow()
|
||||
self.trials.blockSignals(True)
|
||||
self.trials.clear()
|
||||
self.trials.addItem(t("overview.all_trials"), None)
|
||||
if index >= 0:
|
||||
site_id = self.rows[index]["site_id"]
|
||||
for trial in self.review["trials"]:
|
||||
if site_id is not None and trial.get("site_id") == site_id:
|
||||
self.trials.addItem(trial["trial_id"] + " · " + t("calibration." + trial["status"]), trial["trial_id"])
|
||||
self.trials.blockSignals(False)
|
||||
self._show_uv()
|
||||
self._show_response()
|
||||
|
||||
@staticmethod
|
||||
def _clear_chart(chart):
|
||||
chart.removeAllSeries()
|
||||
for axis in chart.axes():
|
||||
axis.hide()
|
||||
chart.removeAxis(axis)
|
||||
axis.deleteLater()
|
||||
|
||||
@staticmethod
|
||||
def _axes(chart):
|
||||
points = [point for series in chart.series() for point in series.points()]
|
||||
for horizontal in (True, False):
|
||||
axis = QValueAxis()
|
||||
values = [p.x() if horizontal else p.y() for p in points]
|
||||
if values:
|
||||
low, high = min(values), max(values)
|
||||
margin = max((high - low) * .1, .1)
|
||||
axis.setRange(low - margin, high + margin)
|
||||
chart.addAxis(axis, Qt.AlignmentFlag.AlignBottom if horizontal else Qt.AlignmentFlag.AlignLeft)
|
||||
for series in chart.series():
|
||||
series.attachAxis(axis)
|
||||
|
||||
def _show_uv(self):
|
||||
self._clear_chart(self.uv_chart)
|
||||
sensitivity = self.map_mode.currentIndex() == 1
|
||||
values = [s["confirmed_fit"]["sensitivity_raw_per_n"] for s in self.review["sites"] if s["confirmed_fit"]]
|
||||
low, high = (min(values), max(values)) if values else (0, 0)
|
||||
colors = {"uncollected": "#929aa7", "incomplete": "#ca4c46", "pending": "#d8911d", "reviewed": "#29835f"}
|
||||
for index, row in enumerate(self.rows):
|
||||
color = QColor(colors[row["status"]])
|
||||
if sensitivity:
|
||||
color = QColor("#929aa7")
|
||||
if row["fit_basis"] == "confirmed":
|
||||
value = row["fit"]["sensitivity_raw_per_n"]
|
||||
ratio = (value - low) / (high - low) if high > low else .5
|
||||
color = QColor.fromHsvF((1 - ratio) * .65, .8, .85)
|
||||
series = QScatterSeries()
|
||||
series.setColor(color)
|
||||
series.setBorderColor(QColor("#151515") if index == self.table.currentRow() else color)
|
||||
series.setMarkerSize(15 if index == self.table.currentRow() else 10)
|
||||
series.append(*row["uv_xy_px"])
|
||||
series.clicked.connect(lambda _point, target=index: self.table.selectRow(target))
|
||||
self.uv_chart.addSeries(series)
|
||||
self._axes(self.uv_chart)
|
||||
definition = self.review["uv_definition"]
|
||||
origin = definition.get("origin_raster_xy_px")
|
||||
shape = definition.get("raster_size_hw")
|
||||
for axis in self.uv_chart.axes(Qt.Orientation.Horizontal):
|
||||
axis.setTitleText("U (px)")
|
||||
if origin and shape:
|
||||
axis.setRange(-origin[0] - .5, shape[1] - origin[0] - .5)
|
||||
for axis in self.uv_chart.axes(Qt.Orientation.Vertical):
|
||||
axis.setTitleText("V (px)")
|
||||
if origin and shape:
|
||||
axis.setRange(origin[1] - shape[0] + .5, origin[1] + .5)
|
||||
self.uv_chart.legend().hide()
|
||||
self.uv_chart.setTitle(t("overview.sensitivity_map").format(low=low, high=high)
|
||||
if sensitivity else t("overview.coverage_map"))
|
||||
|
||||
def _show_response(self):
|
||||
self._clear_chart(self.response_chart)
|
||||
index = self.table.currentRow()
|
||||
site_id = self.rows[index]["site_id"] if index >= 0 else None
|
||||
site = next((s for s in self.review["sites"] if s["site_id"] == site_id), None)
|
||||
trial_id = self.trials.currentData()
|
||||
windows = [w for w in site["windows"] if trial_id is None or w["trial_id"] == trial_id] if site else []
|
||||
self.details_button.setEnabled(site is not None and bool(windows))
|
||||
for trial_index, trial in enumerate(sorted({w["trial_id"] for w in windows})):
|
||||
for branch, color in (("zero", "#657388"), ("load", "#167ac6"), ("unload", "#df8b13")):
|
||||
for confirmed in (True, False):
|
||||
points = [w for w in windows if w["trial_id"] == trial and w["branch"] == branch
|
||||
and w["status"] == "candidate" and w["stability_verified"] == confirmed]
|
||||
if not points:
|
||||
continue
|
||||
series = QScatterSeries()
|
||||
series.setName(trial + " · " + t("calibration." + branch) + " · " + t("overview.confirmed" if confirmed else "overview.candidate"))
|
||||
series.setMarkerShape(QScatterSeries.MarkerShape.MarkerShapeCircle if confirmed else QScatterSeries.MarkerShape.MarkerShapeRectangle)
|
||||
series.setColor(QColor(color).lighter(100 + 20 * (trial_index % 4)))
|
||||
series.setMarkerSize(10 if confirmed else 7)
|
||||
for window in points:
|
||||
series.append(window["reference_n"], window["raw_mean"])
|
||||
self.response_chart.addSeries(series)
|
||||
fit = (site["confirmed_fit"] or site["fit"]) if site else None
|
||||
if fit and windows:
|
||||
training = [w for w in site["windows"] if w["branch"] in {"zero", "load"}
|
||||
and w["status"] == "candidate" and (not site["confirmed_fit"] or w["stability_verified"])]
|
||||
line = QLineSeries()
|
||||
line.setName(t("overview.site_fit"))
|
||||
for load in (min(w["reference_n"] for w in training), max(w["reference_n"] for w in training)):
|
||||
line.append(load, (load - fit["offset_n"]) / fit["gain_n_per_raw"])
|
||||
self.response_chart.addSeries(line)
|
||||
self._axes(self.response_chart)
|
||||
for axis in self.response_chart.axes(Qt.Orientation.Horizontal):
|
||||
axis.setTitleText(t("review.force_axis"))
|
||||
for axis in self.response_chart.axes(Qt.Orientation.Vertical):
|
||||
axis.setTitleText(t("review.raw_axis"))
|
||||
self.response_chart.setTitle(t("overview.response_title"))
|
||||
self.response_chart.legend().setAlignment(Qt.AlignmentFlag.AlignRight)
|
||||
self.selected_summary.setText(t("overview.selected_fit").format(
|
||||
sensitivity=f"{fit['sensitivity_raw_per_n']:.6g}" if fit else "—",
|
||||
basis=t("overview.confirmed" if site and site["confirmed_fit"] else "overview.candidate") if fit else "—",
|
||||
) if windows else t("overview.no_windows"))
|
||||
|
||||
def _import_plan(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, t("overview.import_plan"), str(self.directory), "CSV (*.csv)")
|
||||
if path:
|
||||
try:
|
||||
plan = read_plan(Path(path), self.review["uv_definition"])
|
||||
overview_rows(self.review, plan)
|
||||
self.plan = plan
|
||||
self._refresh()
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
|
||||
def _clear_plan(self):
|
||||
self.plan = None
|
||||
self._refresh()
|
||||
|
||||
def _details(self):
|
||||
row = self.rows[self.table.currentRow()]
|
||||
index = next(i for i, site in enumerate(self.review["sites"]) if site["site_id"] == row["site_id"])
|
||||
dialog = SessionReviewDialog(self.directory, self, review=self.review)
|
||||
dialog.sites.setCurrentIndex(index)
|
||||
trial = self.trials.currentData()
|
||||
if trial:
|
||||
windows = dialog.review["sites"][index]["windows"]
|
||||
match = next((i for i, w in enumerate(windows) if w["trial_id"] == trial), None)
|
||||
if match is not None:
|
||||
dialog.table.selectRow(match)
|
||||
dialog.exec()
|
||||
self.review = dialog.review
|
||||
self._refresh()
|
||||
|
||||
def _save(self):
|
||||
try:
|
||||
path = save_overview(self.review, self.plan)
|
||||
self.message.setText(t("review.saved").format(path=path))
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
|
||||
def _load(self):
|
||||
path, _ = QFileDialog.getOpenFileName(self, t("overview.load"), str(self.directory / "derived"), "JSON (*.json)")
|
||||
if path:
|
||||
try:
|
||||
review, plan = load_overview(self.directory, Path(path))
|
||||
self.review, self.plan = review, plan
|
||||
self._refresh()
|
||||
except (OSError, ValueError, KeyError, TypeError) as exc:
|
||||
self.message.setText(t("calibration.error").format(detail=exc))
|
||||
@@ -19,11 +19,11 @@ from ..session_review import review_session, save_review, load_review
|
||||
class SessionReviewDialog(QDialog):
|
||||
"""Open persisted data independently of the acquisition page's state."""
|
||||
|
||||
def __init__(self, directory: Path, parent=None) -> None:
|
||||
def __init__(self, directory: Path, parent=None, *, review: dict | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle(t("review.title"))
|
||||
self.resize(1080, 760)
|
||||
self.review = review_session(directory)
|
||||
self.review = review if review is not None else review_session(directory)
|
||||
self.directory = directory
|
||||
layout = QVBoxLayout(self)
|
||||
self.notice = QLabel(t("review.notice"), self)
|
||||
|
||||
Reference in New Issue
Block a user