feat(export): batch wafer-map PNG export from file list

- BatchExport worker + BatchExportController (threaded, busy flag)
- SourcePanel: Export Maps button → FolderDialog → batch export
  with progress/summary banner (dismissible)
- StreamControlPanel: name single export after loaded CSV + timestamp
This commit is contained in:
jack
2026-07-11 23:53:45 -07:00
parent c26de492d7
commit 7d98759b43
9 changed files with 448 additions and 8 deletions
+110 -6
View File
@@ -1,13 +1,31 @@
import QtQuick
import QtQuick.Controls
import QtQuick.Dialogs
import QtQuick.Layouts
import QtQuick.Controls.impl
import ISC
import ".."
ColumnLayout {
id: root
spacing: 6
property string exportSummary: ""
Connections {
target: batchExportController
function onExportFinished(summary) {
root.exportSummary = summary
exportBannerHideTimer.restart()
}
}
Timer {
id: exportBannerHideTimer
interval: 5000
onTriggered: root.exportSummary = ""
}
// ── Section label & Refresh ───────────────────────────────────────────
RowLayout {
Layout.fillWidth: true
@@ -26,6 +44,29 @@ ColumnLayout {
Layout.alignment: Qt.AlignVCenter
}
Button {
id: refreshBtn
implicitWidth: 28
implicitHeight: 28
hoverEnabled: true
background: Rectangle {
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: IconImage {
source: "../icons/refresh.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor
}
onClicked: file_browser.refreshFiles()
AppToolTip {
visible: refreshBtn.hovered
text: "Refresh"
}
}
Button {
id: editBtn
implicitWidth: 28
@@ -52,27 +93,90 @@ ColumnLayout {
}
}
Item { implicitWidth: 8 }
Button {
id: refreshBtn
id: batchExportBtn
implicitWidth: 28
implicitHeight: 28
hoverEnabled: true
enabled: file_browser.files.length > 0 && !batchExportController.busy
opacity: enabled ? 1.0 : 0.4
background: Rectangle {
color: refreshBtn.pressed ? Theme.buttonPressed : refreshBtn.hovered ? Theme.buttonNeutralHover : "transparent"
color: batchExportBtn.pressed ? Theme.buttonPressed : batchExportBtn.hovered ? Theme.buttonNeutralHover : "transparent"
radius: Theme.radiusSm
}
contentItem: IconImage {
source: "../icons/refresh.svg"
source: "../icons/images.svg"
sourceSize.width: 16
sourceSize.height: 16
color: Theme.bodyColor
}
onClicked: file_browser.refreshFiles()
onClicked: batchExportDirDialog.open()
AppToolTip {
visible: refreshBtn.hovered
text: "Refresh"
visible: batchExportBtn.hovered && batchExportBtn.enabled
text: "Batch Export — render every listed file's wafer map to a PNG in select folder"
}
AppToolTip {
visible: batchExportBtn.hovered && !batchExportBtn.enabled
text: batchExportController.busy
? "Export already in progress…"
: "No files to export — load a folder with CSV files first"
}
}
}
// ── Batch export progress / summary banner ──────────────────────────────
Rectangle {
visible: batchExportController.busy || root.exportSummary !== ""
Layout.fillWidth: true
height: exportBannerRow.implicitHeight + 12
radius: Theme.radiusSm
color: Theme.cardBackground
border.color: Theme.statusWarningColor
border.width: 1
RowLayout {
id: exportBannerRow
anchors.fill: parent
anchors.margins: 6
spacing: 6
Label {
text: batchExportController.busy ? "Exporting wafer maps…" : root.exportSummary
color: Theme.statusWarningColor
font.pixelSize: Theme.fontSm
wrapMode: Text.WordWrap
Layout.fillWidth: true
}
Button {
visible: !batchExportController.busy
flat: true
implicitWidth: 20
implicitHeight: 20
onClicked: root.exportSummary = ""
background: Rectangle { color: "transparent" }
contentItem: Label {
text: "✕"
color: Theme.statusWarningColor
font.pixelSize: Theme.fontXs
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
FolderDialog {
id: batchExportDirDialog
title: "Export Wafer Maps To…"
onAccepted: {
var dir = String(selectedFolder).replace(/^file:\/\//, "");
var paths = file_browser.files.map(function(row) { return row.fileName; });
batchExportController.start(paths, dir);
}
}
@@ -280,8 +280,16 @@ Rectangle {
opacity: enabled ? 1 : 0.4
onClicked: {
// Default into <saveDataDir>/Export (created on demand)
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/wafer_map.png"
// Default into <saveDataDir>/Export (created on demand),
// named after the loaded CSV so exports are traceable
// back to their source (falls back to a generic name for
// a live stream with no review file loaded).
var loaded = streamController.loadedFile
var baseName = loaded
? loaded.split("/").pop().replace(/\.[^.]+$/, "")
: "wafer_map"
var ts = Qt.formatDateTime(new Date(), "yyyyMMdd_HHmmss")
exportDialog.selectedFile = "file://" + deviceController.exportDir() + "/" + baseName + "_" + ts + ".png"
exportDialog.open()
}
+16
View File
@@ -0,0 +1,16 @@
<svg
xmlns="http://www.w3.org/2000/svg"
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
stroke-width="2"
stroke-linecap="round"
stroke-linejoin="round"
>
<path d="M18 22H4a2 2 0 0 1-2-2V6" />
<path d="m22 13-1.296-1.296a2.41 2.41 0 0 0-3.408 0L11 18" />
<circle cx="12" cy="8" r="2" />
<rect width="16" height="16" x="6" y="2" rx="2" />
</svg>

After

Width:  |  Height:  |  Size: 399 B

+5
View File
@@ -7,6 +7,7 @@ from PySide6.QtQml import QQmlApplicationEngine
from PySide6.QtQuickControls2 import QQuickStyle
from PySide6.QtWidgets import QApplication
from pygui.backend.controllers.batch_export_controller import BatchExportController
from pygui.backend.controllers.device_controller import DeviceController
from pygui.backend.controllers.session_controller import SessionController
from pygui.backend.data.constants import default_data_dir
@@ -79,6 +80,10 @@ def main() -> int:
stream_controller = SessionController()
engine.rootContext().setContextProperty("streamController", stream_controller)
# ===== Batch Export Controller (SourcePanel batch PNG export) =====
batch_export_controller = BatchExportController()
engine.rootContext().setContextProperty("batchExportController", batch_export_controller)
# ===== QML Startup =====
# The "ISC" QML module lives alongside this file (src/pygui/ISC), so the
# package directory is the import path the engine searches for qmldir.
@@ -0,0 +1,62 @@
from __future__ import annotations
import logging
import threading
from PySide6.QtCore import Property, QObject, Qt, Signal, Slot
from pygui.backend.utils import slot_error_boundary
from pygui.backend.wafer.batch_export import BatchExportResult, export_batch
log = logging.getLogger(__name__)
class BatchExportController(QObject):
busyChanged = Signal()
exportFinished = Signal(str)
_batchFinished = Signal(list)
def __init__(self, parent: QObject | None = None) -> None:
super().__init__(parent)
self._busy = False
self._batchFinished.connect(self._handle_batch_finished, Qt.ConnectionType.QueuedConnection)
@Property(bool, notify=busyChanged)
def busy(self) -> bool:
return self._busy
@Slot("QVariantList", str)
def start(self, file_paths: list, output_dir: str) -> None:
if self._busy:
return
self._busy = True
self.busyChanged.emit()
threading.Thread(
target=self._worker, args=(list(file_paths), output_dir), daemon=True
).start()
def _worker(self, file_paths: list[str], output_dir: str) -> None:
try:
results = export_batch(file_paths, output_dir)
except Exception as exc:
log.exception("Batch export worker crashed: %s", exc)
results = []
self._batchFinished.emit(results)
@Slot(list)
@slot_error_boundary
def _handle_batch_finished(self, results: list[BatchExportResult]) -> None:
try:
total = len(results)
succeeded = sum(1 for r in results if r.success)
failed = total - succeeded
summary = (
f"Exported {succeeded}/{total}{failed} failed"
if failed
else f"Exported {succeeded}/{total}"
)
self.exportFinished.emit(summary)
finally:
self._busy = False
self.busyChanged.emit()
+68
View File
@@ -0,0 +1,68 @@
from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
from pygui.backend.data.data_records import (
is_official_csv,
read_data_records,
read_official_csv,
)
from pygui.backend.models.frame_player import frames_from_wafer_data
from pygui.backend.visualization.wafer_map_item import WaferMapItem
from pygui.backend.wafer.zwafer_parser import ZWaferParser
_EXPORT_SIZE = 600
@dataclass
class BatchExportResult:
file_path: str
output_path: str
success: bool
error: str = ""
def export_batch(
file_paths: list[str], output_dir: str, timestamp: str | None = None
) -> list[BatchExportResult]:
"""Render the last frame of each file to a PNG in output_dir.
Skips files that fail to parse or render, continuing with the rest.
"""
ts = timestamp or datetime.now().strftime("%Y%m%d_%H%M%S")
results = []
for file_path in file_paths:
results.append(_export_one(file_path, output_dir, ts))
return results
def _export_one(file_path: str, output_dir: str, ts: str) -> BatchExportResult:
base_name = Path(file_path).stem
output_path = str(Path(output_dir) / f"{base_name}_{ts}.png")
try:
if is_official_csv(file_path):
sensors, records = read_official_csv(file_path)
else:
data, _ = ZWaferParser().parse(file_path)
if data is None or not data.sensors:
return BatchExportResult(file_path, output_path, False, "Invalid layout or missing sensors")
sensors = data.sensors
records = read_data_records(file_path)
frames = frames_from_wafer_data(None, records)
if not sensors or not frames:
return BatchExportResult(file_path, output_path, False, "No sensors or frames found")
item = WaferMapItem()
item.setWidth(_EXPORT_SIZE)
item.setHeight(_EXPORT_SIZE)
item.sensors = [{"label": s.label, "x": s.x, "y": s.y} for s in sensors] # type: ignore[method-assign]
item.values = frames[-1].values # type: ignore[method-assign]
if not item.export_image(output_path):
return BatchExportResult(file_path, output_path, False, "Export render failed")
return BatchExportResult(file_path, output_path, True)
except Exception as exc:
return BatchExportResult(file_path, output_path, False, str(exc))