feat: Stage 4 & 5 - Profile Calculation (colprof) & Verification (profcheck) #14

Merged
gronod merged 1 commits from feature/stage-4-and-5 into feature/stage-3-chartread 2026-08-22 18:27:23 +01:00
10 changed files with 666 additions and 5 deletions
+20
View File
@@ -0,0 +1,20 @@
#!/bin/bash
# Mock script for colprof
# Simulates colprof execution and outputs progress log
basename="$1"
# Find last argument if -D or other flags are used
for arg in "$@"; do
basename="$arg"
done
echo "colprof: Starting profile calculation for $basename"
sleep 1
echo "Gamut mapping calculation..."
sleep 1
echo "Fitting cLUT grid points..."
sleep 1
echo "Writing ICC profile $basename.icc..."
touch "$basename.icc"
echo "Done."
exit 0
+14
View File
@@ -0,0 +1,14 @@
#!/bin/bash
# Mock script for profcheck
# Simulates profcheck verification output
echo "profcheck: Checking profile accuracy..."
sleep 1
cat << 'EOF'
{"event": "profcheck_complete", "avg_de": 0.85, "max_de": 2.41, "rms_de": 1.02}
EOF
echo "Summary:"
echo " avg. dE = 0.85"
echo " max. dE = 2.41"
echo " rms. dE = 1.02"
exit 0
+116
View File
@@ -204,6 +204,94 @@ pub async fn run_chartread(
state.spawn(app, id, binary, args, cwd).await state.spawn(app, id, binary, args, cwd).await
} }
#[derive(Debug, Deserialize, Serialize)]
pub struct ColprofConfig {
pub quality: String,
pub algorithm: String,
pub description: String,
pub copyright: Option<String>,
pub basename: String,
pub cwd: String,
}
pub fn build_colprof_args(config: &ColprofConfig) -> Vec<String> {
let mut args = vec![
"-v".to_string(),
"-u".to_string(),
"-q".to_string(),
config.quality.clone(),
"-a".to_string(),
config.algorithm.clone(),
"-D".to_string(),
config.description.clone(),
];
if let Some(copyright) = &config.copyright {
if !copyright.trim().is_empty() {
args.push("-C".to_string());
args.push(copyright.clone());
}
}
args.push(config.basename.clone());
args
}
#[tauri::command]
pub async fn run_colprof(
app: AppHandle,
state: State<'_, ProcessManager>,
config: ColprofConfig,
) -> Result<(), String> {
let binary = resolve_binary(app.clone(), "colprof".to_string()).await?;
let args = build_colprof_args(&config);
let id = format!("colprof_{}", config.basename);
let cwd = if config.cwd.trim().is_empty() {
None
} else {
Some(config.cwd.clone())
};
state.spawn(app, id, binary, args, cwd).await
}
#[derive(Debug, Deserialize, Serialize)]
pub struct ProfcheckConfig {
pub ti3_path: String,
pub icc_path: String,
pub cwd: String,
}
pub fn build_profcheck_args(config: &ProfcheckConfig) -> Vec<String> {
vec![
"-v".to_string(),
"-k".to_string(),
"-u".to_string(),
config.ti3_path.clone(),
config.icc_path.clone(),
]
}
#[tauri::command]
pub async fn run_profcheck(
app: AppHandle,
state: State<'_, ProcessManager>,
config: ProfcheckConfig,
) -> Result<(), String> {
let binary = resolve_binary(app.clone(), "profcheck".to_string()).await?;
let args = build_profcheck_args(&config);
let id = format!("profcheck_{}", config.ti3_path);
let cwd = if config.cwd.trim().is_empty() {
None
} else {
Some(config.cwd.clone())
};
state.spawn(app, id, binary, args, cwd).await
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;
@@ -287,4 +375,32 @@ mod tests {
let args = build_chartread_args(&config); let args = build_chartread_args(&config);
assert_eq!(args, vec!["-v", "-u", "my_profile"]); assert_eq!(args, vec!["-v", "-u", "my_profile"]);
} }
#[test]
fn test_build_colprof_args() {
let config = ColprofConfig {
quality: "h".to_string(),
algorithm: "l".to_string(),
description: "My Profile".to_string(),
copyright: Some("2026 ACME".to_string()),
basename: "my_profile".to_string(),
cwd: "/home/user".to_string(),
};
let args = build_colprof_args(&config);
assert_eq!(
args,
vec!["-v", "-u", "-q", "h", "-a", "l", "-D", "My Profile", "-C", "2026 ACME", "my_profile"]
);
}
#[test]
fn test_build_profcheck_args() {
let config = ProfcheckConfig {
ti3_path: "my_profile.ti3".to_string(),
icc_path: "my_profile.icc".to_string(),
cwd: "/home/user".to_string(),
};
let args = build_profcheck_args(&config);
assert_eq!(args, vec!["-v", "-k", "-u", "my_profile.ti3", "my_profile.icc"]);
}
} }
+2
View File
@@ -18,6 +18,8 @@ pub fn run() {
commands::run_printtarg, commands::run_printtarg,
commands::read_file_base64, commands::read_file_base64,
commands::run_chartread, commands::run_chartread,
commands::run_colprof,
commands::run_profcheck,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");
+91 -5
View File
@@ -218,15 +218,101 @@
<!-- Stage 4: colprof --> <!-- Stage 4: colprof -->
<section id="stage-4" class="stage hidden"> <section id="stage-4" class="stage hidden">
<h2>Create Profile</h2> <h2>Create Profile</h2>
<p>Calculate the ICC profile.</p> <p>Calculate the ICC profile from your target measurement data.</p>
<button class="primary" id="btnProfile">Create Profile (.icc)</button>
<div class="form-container">
<div class="form-group">
<label>Profile Quality</label>
<select id="colprofQuality">
<option value="l">Low (Fast draft)</option>
<option value="m" selected>Medium (Standard)</option>
<option value="h">High (Recommended)</option>
<option value="u">Ultra (Maximum precision - slow)</option>
</select>
</div>
<div class="form-group">
<label>Profile Description</label>
<input type="text" id="colprofDescription" placeholder="e.g. Epson P600 Lustre Photo">
</div>
<div class="form-group">
<label>Copyright / Creator (Optional)</label>
<input type="text" id="colprofCopyright" placeholder="e.g. Copyright (c) 2026 My Studio">
</div>
<div class="form-group">
<label>Algorithm Type</label>
<select id="colprofAlgorithm">
<option value="l" selected>Lab cLUT (Standard Lookup Table)</option>
<option value="x">XYZ cLUT</option>
<option value="X">Display XYZ cLUT + Matrix</option>
<option value="m">Matrix Only (Simple)</option>
</select>
</div>
</div>
<button class="primary" id="btnCreateProfile">Calculate Profile (.icc)</button>
<!-- Spinner and Progress Stage -->
<div class="spinner-container hidden" id="colprofSpinnerContainer">
<div class="spinner"></div>
<div class="stage-label" id="colprofStageLabel">Calculating ICC Profile...</div>
</div>
<!-- Success Card -->
<div class="success-card hidden" id="colprofSuccessCard">
<h3>🎉 Profile Calculation Complete</h3>
<p id="colprofSuccessInfo"></p>
<button class="primary" id="btnGoToVerify">Proceed to Verification →</button>
</div>
<!-- Process Log -->
<div class="log-container hidden" id="colprofLogContainer">
<h4>Process Output</h4>
<pre id="colprofLog"></pre>
</div>
</section> </section>
<!-- Stage 5: profcheck --> <!-- Stage 5: profcheck -->
<section id="stage-5" class="stage hidden"> <section id="stage-5" class="stage hidden">
<h2>Verify</h2> <h2>Verify Profile</h2>
<p>Analyze the generated profile quality.</p> <p>Check the numerical accuracy of your profile against the original measurement data.</p>
<button class="primary" id="btnVerify">Verify Profile</button>
<button class="primary" id="btnVerify">Run Profile Verification</button>
<!-- Report Card -->
<div class="report-card hidden" id="profcheckReportCard">
<div class="report-header">
<h3>Profile Quality Report</h3>
<div id="profcheckBadge" class="report-badge">GOOD</div>
</div>
<div class="report-metrics">
<div class="metric-box">
<span class="metric-value" id="profcheckAvgDe">0.00</span>
<span class="metric-label">Average ΔE₀₀</span>
</div>
<div class="metric-box">
<span class="metric-value" id="profcheckMaxDe">0.00</span>
<span class="metric-label">Peak ΔE₀₀</span>
</div>
<div class="metric-box">
<span class="metric-value" id="profcheckRmsDe">0.00</span>
<span class="metric-label">RMS ΔE₀₀</span>
</div>
</div>
<div class="report-footnote">
Professional printer profiles typically achieve average ΔE₀₀ &lt; 1.0, and acceptable profiles &lt; 2.0.
</div>
</div>
<!-- Process Log -->
<div class="log-container hidden" id="profcheckLogContainer">
<h4>Verification Output</h4>
<pre id="profcheckLog"></pre>
</div>
</section> </section>
</main> </main>
</div> </div>
+8
View File
@@ -1,6 +1,8 @@
import { initTargen } from './targen.js'; import { initTargen } from './targen.js';
import { initPrinttarg } from './printtarg.js'; import { initPrinttarg } from './printtarg.js';
import { initChartread } from './chartread.js'; import { initChartread } from './chartread.js';
import { initColprof } from './colprof.js';
import { initProfcheck } from './profcheck.js';
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
@@ -35,4 +37,10 @@ document.addEventListener('DOMContentLoaded', () => {
// Initialize Stage 3 // Initialize Stage 3
initChartread(); initChartread();
// Initialize Stage 4
initColprof();
// Initialize Stage 5
initProfcheck();
}); });
+2
View File
@@ -1,6 +1,7 @@
const { invoke } = window.__TAURI__.core; const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event; const { listen } = window.__TAURI__.event;
import { startSwatchListener, stopSwatchListener } from './swatch_grid.js'; import { startSwatchListener, stopSwatchListener } from './swatch_grid.js';
import { setStage3Result } from './colprof.js';
// Module-level state: set by Stage 2 when it completes // Module-level state: set by Stage 2 when it completes
let stage2Basename = ""; let stage2Basename = "";
@@ -150,6 +151,7 @@ export function initChartread() {
setState(STATE.FINISHED); setState(STATE.FINISHED);
setPrompt("✅ Measurement complete! .ti3 file has been saved."); setPrompt("✅ Measurement complete! .ti3 file has been saved.");
logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n"; logPre.textContent += "\n[SUCCESS] chartread completed. .ti3 file written.\n";
setStage3Result(stage2Basename, stage2Cwd);
advanceToStage4(); advanceToStage4();
} else { } else {
setState(STATE.FINISHED); setState(STATE.FINISHED);
+133
View File
@@ -0,0 +1,133 @@
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
import { setStage4Result } from './profcheck.js';
let chartreadBasename = "";
let chartreadCwd = "";
/**
* Called by chartread.js after Stage 3 completes.
*/
export function setStage3Result(basename, cwd) {
chartreadBasename = basename;
chartreadCwd = cwd;
}
export function initColprof() {
const qualitySelect = document.getElementById("colprofQuality");
const algorithmSelect = document.getElementById("colprofAlgorithm");
const descInput = document.getElementById("colprofDescription");
const copyrightInput = document.getElementById("colprofCopyright");
const btnCreateProfile = document.getElementById("btnCreateProfile");
const spinnerContainer = document.getElementById("colprofSpinnerContainer");
const stageLabel = document.getElementById("colprofStageLabel");
const logContainer = document.getElementById("colprofLogContainer");
const logPre = document.getElementById("colprofLog");
const successCard = document.getElementById("colprofSuccessCard");
const successInfo = document.getElementById("colprofSuccessInfo");
const btnGoToVerify = document.getElementById("btnGoToVerify");
if (!btnCreateProfile) return;
btnCreateProfile.addEventListener("click", async () => {
const basename = chartreadBasename || "test_target";
const cwd = chartreadCwd || "";
const description = descInput.value.trim() || basename;
logPre.textContent = "";
logContainer.classList.remove("hidden");
spinnerContainer.classList.remove("hidden");
successCard.classList.add("hidden");
btnCreateProfile.disabled = true;
stageLabel.textContent = "Initializing colprof...";
const config = {
quality: qualitySelect.value,
algorithm: algorithmSelect.value,
description: description,
copyright: copyrightInput.value.trim() || null,
basename: basename,
cwd: cwd,
};
const processId = `colprof_${basename}`;
try {
const unlistenStdout = await listen("process:stdout", (event) => {
if (event.payload.id === processId && event.payload.line) {
const line = event.payload.line;
logPre.textContent += line + "\n";
logPre.scrollTop = logPre.scrollHeight;
// Parse coarse progress stages from stdout
const lineLower = line.toLowerCase();
if (lineLower.includes("gamut mapping")) {
stageLabel.textContent = "Gamut mapping calculation in progress...";
} else if (lineLower.includes("fitting") || lineLower.includes("clut")) {
stageLabel.textContent = "Fitting cLUT grid points...";
} else if (lineLower.includes("writing") || lineLower.includes("icc profile")) {
stageLabel.textContent = "Writing ICC profile header & tags...";
}
}
});
const unlistenStderr = await listen("process:stderr", (event) => {
if (event.payload.id === processId && event.payload.line) {
logPre.textContent += "ERR: " + event.payload.line + "\n";
logPre.scrollTop = logPre.scrollHeight;
}
});
const unlistenExit = await listen("process:exit", (event) => {
if (event.payload.id === processId) {
unlistenStdout();
unlistenStderr();
unlistenExit();
spinnerContainer.classList.add("hidden");
btnCreateProfile.disabled = false;
if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] colprof completed. Profile generated.\n";
const iccFilename = `${basename}.icc`;
successInfo.textContent = `Profile: ${iccFilename} (${description})`;
successCard.classList.remove("hidden");
setStage4Result(basename, cwd);
} else {
logPre.textContent += `\n[ERROR] colprof exited with code ${event.payload.code}.\n`;
}
}
});
await invoke("run_colprof", { config });
} catch (err) {
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
spinnerContainer.classList.add("hidden");
btnCreateProfile.disabled = false;
}
});
if (btnGoToVerify) {
btnGoToVerify.addEventListener("click", () => {
advanceToStage5();
});
}
}
function advanceToStage5() {
const steps = document.querySelectorAll('.step');
const stages = document.querySelectorAll('.stage');
steps.forEach(s => s.classList.remove('active'));
if (steps[4]) steps[4].classList.add('active');
stages.forEach(s => {
s.classList.remove('active');
s.classList.add('hidden');
});
if (stages[4]) {
stages[4].classList.remove('hidden');
stages[4].classList.add('active');
}
}
+137
View File
@@ -0,0 +1,137 @@
const { invoke } = window.__TAURI__.core;
const { listen } = window.__TAURI__.event;
let profileBasename = "";
let profileCwd = "";
/**
* Called by colprof.js after Stage 4 completes.
*/
export function setStage4Result(basename, cwd) {
profileBasename = basename;
profileCwd = cwd;
}
export function initProfcheck() {
const btnVerify = document.getElementById("btnVerify");
const logContainer = document.getElementById("profcheckLogContainer");
const logPre = document.getElementById("profcheckLog");
const reportCard = document.getElementById("profcheckReportCard");
const avgDeEl = document.getElementById("profcheckAvgDe");
const maxDeEl = document.getElementById("profcheckMaxDe");
const rmsDeEl = document.getElementById("profcheckRmsDe");
const badgeEl = document.getElementById("profcheckBadge");
if (!btnVerify) return;
btnVerify.addEventListener("click", async () => {
const basename = profileBasename || "test_target";
const cwd = profileCwd || "";
const sep = cwd.includes('\\') ? '\\' : '/';
const ti3Path = cwd ? `${cwd}${sep}${basename}.ti3` : `${basename}.ti3`;
const iccPath = cwd ? `${cwd}${sep}${basename}.icc` : `${basename}.icc`;
logPre.textContent = "";
logContainer.classList.remove("hidden");
reportCard.classList.add("hidden");
btnVerify.disabled = true;
const config = {
ti3_path: ti3Path,
icc_path: iccPath,
cwd: cwd,
};
const processId = `profcheck_${ti3Path}`;
let stdoutAccumulator = "";
try {
const unlistenStdout = await listen("process:stdout", (event) => {
if (event.payload.id === processId && event.payload.line) {
stdoutAccumulator += event.payload.line + "\n";
logPre.textContent += event.payload.line + "\n";
logPre.scrollTop = logPre.scrollHeight;
}
});
const unlistenStderr = await listen("process:stderr", (event) => {
if (event.payload.id === processId && event.payload.line) {
logPre.textContent += "ERR: " + event.payload.line + "\n";
logPre.scrollTop = logPre.scrollHeight;
}
});
const unlistenExit = await listen("process:exit", (event) => {
if (event.payload.id === processId) {
unlistenStdout();
unlistenStderr();
unlistenExit();
btnVerify.disabled = false;
if (event.payload.code === 0) {
logPre.textContent += "\n[SUCCESS] profcheck verification finished.\n";
parseAndRenderReport(stdoutAccumulator);
} else {
logPre.textContent += `\n[ERROR] profcheck exited with code ${event.payload.code}.\n`;
}
}
});
await invoke("run_profcheck", { config });
} catch (err) {
logPre.textContent += `\n[INVOKE ERROR] ${err}\n`;
btnVerify.disabled = false;
}
});
function parseAndRenderReport(stdout) {
reportCard.classList.remove("hidden");
let avgDe = 0.0;
let maxDe = 0.0;
let rmsDe = 0.0;
// Check if JSON output is present
const jsonMatch = stdout.match(/\{[\s\S]*"avg_de"[\s\S]*\}/);
if (jsonMatch) {
try {
const json = JSON.parse(jsonMatch[0]);
avgDe = json.avg_de || 0;
maxDe = json.max_de || json.peak_de || 0;
rmsDe = json.rms_de || 0;
} catch (e) {
console.error("JSON parse error:", e);
}
} else {
// Regex fallbacks for standard profcheck output
const avgMatch = stdout.match(/avg\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/average\s*dE\s*:\s*([\d\.]+)/i);
const maxMatch = stdout.match(/max\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/peak\s*dE\s*:\s*([\d\.]+)/i);
const rmsMatch = stdout.match(/rms\.\s*dE\s*=\s*([\d\.]+)/i) || stdout.match(/rms\s*dE\s*:\s*([\d\.]+)/i);
if (avgMatch) avgDe = parseFloat(avgMatch[1]);
if (maxMatch) maxDe = parseFloat(maxMatch[1]);
if (rmsMatch) rmsDe = parseFloat(rmsMatch[1]);
}
avgDeEl.textContent = avgDe.toFixed(2);
maxDeEl.textContent = maxDe.toFixed(2);
rmsDeEl.textContent = rmsDe.toFixed(2);
// Quality verdict
badgeEl.className = "report-badge";
if (avgDe < 1.0) {
badgeEl.textContent = "EXCELLENT";
badgeEl.classList.add("badge-excellent");
} else if (avgDe < 2.0) {
badgeEl.textContent = "GOOD";
badgeEl.classList.add("badge-good");
} else if (avgDe < 4.0) {
badgeEl.textContent = "ACCEPTABLE";
badgeEl.classList.add("badge-acceptable");
} else {
badgeEl.textContent = "POOR";
badgeEl.classList.add("badge-poor");
}
}
}
+143
View File
@@ -499,3 +499,146 @@ button.danger:hover {
overflow-y: auto; overflow-y: auto;
white-space: pre-wrap; white-space: pre-wrap;
} }
/* ========================================
Stage 4: Spinner & Success Card
======================================== */
.spinner-container {
display: flex;
align-items: center;
gap: 16px;
background: var(--panel-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 16px;
margin-top: 16px;
margin-bottom: 16px;
}
.spinner {
width: 24px;
height: 24px;
border: 3px solid rgba(255, 255, 255, 0.2);
border-top-color: var(--accent-color);
border-radius: 50%;
animation: spin 1s linear infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
.stage-label {
font-size: 1rem;
color: #ddd;
}
.success-card {
background: rgba(76, 175, 80, 0.1);
border: 1px solid #4caf50;
border-radius: 8px;
padding: 20px;
margin-top: 16px;
margin-bottom: 16px;
}
.success-card h3 {
color: #4caf50;
margin-top: 0;
margin-bottom: 8px;
}
.success-card p {
color: #ccc;
margin-bottom: 16px;
}
/* ========================================
Stage 5: Report Card
======================================== */
.report-card {
background: var(--panel-color);
border: 1px solid var(--border-color);
border-radius: 8px;
padding: 20px;
margin-top: 16px;
margin-bottom: 16px;
}
.report-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 20px;
}
.report-header h3 {
margin: 0;
font-size: 1.2rem;
}
.report-badge {
padding: 4px 12px;
border-radius: 12px;
font-weight: bold;
font-size: 0.85rem;
letter-spacing: 0.5px;
}
.badge-excellent {
background: rgba(76, 175, 80, 0.2);
color: #4caf50;
border: 1px solid #4caf50;
}
.badge-good {
background: rgba(33, 150, 243, 0.2);
color: #2196f3;
border: 1px solid #2196f3;
}
.badge-acceptable {
background: rgba(255, 152, 0, 0.2);
color: #ff9800;
border: 1px solid #ff9800;
}
.badge-poor {
background: rgba(244, 67, 54, 0.2);
color: #f44336;
border: 1px solid #f44336;
}
.report-metrics {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(120px, 1fr));
gap: 16px;
margin-bottom: 16px;
}
.metric-box {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 16px;
text-align: center;
}
.metric-value {
display: block;
font-size: 1.8rem;
font-weight: bold;
color: #fff;
}
.metric-label {
font-size: 0.8rem;
color: #888;
}
.report-footnote {
font-size: 0.85rem;
color: #888;
border-top: 1px dashed var(--border-color);
padding-top: 12px;
}