Files
argyllcms/doc/chartread_integration_guide.md

332 lines
13 KiB
Markdown

# ArgyllCMS `chartread -u` Subprocess Integration Specification & License Isolation Guide
This document specifies the communication protocol, JSON payload schema, process lifecycle, and licensing isolation architecture for integrating `chartread -u` into external user interfaces and control software.
---
## 1. Architectural & Licensing Boundary (AGPLv3 Isolation)
ArgyllCMS is licensed under the **GNU Affero General Public License (AGPL) Version 3**. To ensure a separate host project (proprietary, MIT, Apache, etc.) is **not tainted** by the AGPLv3 copyleft terms, the integration must maintain a strict, arm's-length inter-process communication (IPC) boundary:
### Core Isolation Rules
1. **No Library Linking**: The host application **must never** statically or dynamically link (`#include`, `.so`, `.dylib`, `.dll`, `.a`) against any ArgyllCMS C libraries (`libinst`, `libicc`, `libcgats`, `libyajl`, etc.).
2. **Subprocess Isolation via Standard OS Pipes**: `chartread` must run strictly as a standalone, decoupled child process.
3. **Standard IPC Only**: Communication is conducted exclusively over standard POSIX / Win32 file descriptors (`stdin`, `stdout`, `stderr`).
4. **Independent Binary Distribution**: The `chartread` binary should be treated as an external utility tool invoked by the OS shell or process manager.
```
+-------------------------------------------------------------+
| Host Application |
| (Electron, Web App, Qt, Python, Rust, etc.) |
| |
| +-----------------------------------------------------+ |
| | Line-by-Line Subprocess Stream Reader | |
| +-----------------------------------------------------+ |
+------------------------------|------------------------------+
|
Standard Pipes | (stdin / stdout / stderr)
|
+------------------------------v------------------------------+
| Isolated Child Process |
| `chartread -u ...` |
| (AGPLv3 Licensed Binary) |
+-------------------------------------------------------------+
```
---
## 2. Command Invocation
### Command Syntax
```bash
chartread [options] -u <target_basename>
```
* **`-u` Flag**: Enables real-time emission of JSON records to `stdout`.
* `<target_basename>`: The base name of the input `.ti2` target file and output `.ti3` measurement file (without extension).
### Key Command Options
| Flag | Description |
| :--- | :--- |
| `-u` | **Required for streaming**: Emits `ROW_COLORS_JSON: ...` payloads on `stdout`. |
| `-p` | Patch-by-patch spot mode (emits JSON after each individual patch). |
| `-n` | Disable spectral readings (omits `spectral` field from JSON). |
| `-c <port>` | Select communication port / instrument index. |
| `-d` | Display measurement mode. |
| `-t` | Transmission measurement mode. |
| `-e` | Emissive measurement mode. |
---
## 3. Stream Protocol & Framing Specification
When `-u` is provided, `chartread` outputs two types of lines to `stdout`:
1. **Human-Readable Status / Prompt Lines**: Unprefixed text intended for user prompts or diagnostics (e.g. `Hit [Space] to read strip A`).
2. **Structured JSON Events**: Single-line JSON objects strictly prefixed by the marker:
```
ROW_COLORS_JSON: <json_object>\n
```
### Stream Guarantees
* **Line-Delimited**: Every JSON event is serialized as a single, uninterrupted line terminated with `\n`.
* **Immediate Flush**: `chartread` explicitly invokes `fflush(stdout)` immediately after emitting each JSON line, guaranteeing low latency without OS-level output buffering.
* **Deterministic Ordering**: Patches within a row are always indexed in canonical left-to-right strip order (bi-directional scan reversals are normalized internally before emission).
---
## 4. JSON Payload Schema
### Top-Level Object Schema
```json
ROW_COLORS_JSON: {
"event": "row_complete",
"row_id": "A",
"row_index": 0,
"total_rows": 12,
"patch_count": 21,
"patches": [
/* Array of Patch Objects */
]
}
```
| Field | Type | Description |
| :--- | :--- | :--- |
| `event` | `string` | Event identifier. Currently always `"row_complete"`. |
| `row_id` | `string` | Human-readable label of the row/strip (e.g. `"A"`, `"B"`, `"1"`). |
| `row_index` | `integer` | 0-based index of the row within the target chart (`0 ... total_rows - 1`). |
| `total_rows` | `integer` | Total number of rows/passes defined in the target chart. |
| `patch_count`| `integer` | Number of patch elements contained in this row payload. |
| `patches` | `array` | List of individual patch data objects. |
---
### Patch Object Schema
```json
{
"id": "1",
"loc": "A1",
"is_pad": false,
"device": [0.0, 50.0, 100.0, 0.0],
"expected": {
"XYZ": [18.4210, 20.1234, 15.6789],
"Lab": [51.98, -8.45, 12.32]
},
"measured": {
"XYZ": [18.5120, 20.0451, 15.7100],
"Lab": [51.89, -8.31, 12.15],
"spectral": {
"bands": 36,
"start_nm": 380.0,
"end_nm": 730.0,
"norm": 100.0,
"values": [0.0120, 0.0135, 0.0180, 0.0245]
}
}
}
```
| Field | Type | Description |
| :--- | :--- | :--- |
| `id` | `string` | Patch ID string from `.ti2` file. If `"0"`, indicates a spacer/padding patch. |
| `loc` | `string` | Physical location coordinate string (e.g. `"A1"`, `"B12"`). |
| `is_pad` | `boolean` | `true` if patch is an alignment/lead-in spacer patch (`id == "0"`). UIs should typically render these distinctly or skip them in analysis. |
| `device` | `array[float]` | Device colorant drive values scaled to percentage `0.0 ... 100.0%` (e.g. `[C, M, Y, K]` or `[R, G, B]`). |
| `expected` | `object` *(optional)* | Expected reference values from `.ti2` (omitted if no reference data is present). |
| `expected.XYZ` | `array[float][3]` | Reference CIE XYZ values on reference scale `0.0 ... 100.0`. |
| `expected.Lab` | `array[float][3]` | Reference D50 $L^*a^*b^*$ computed via standard CIE transformation. |
| `measured` | `object` | Actual instrument readings. |
| `measured.XYZ` | `array[float][3]` | Measured CIE XYZ values on reference scale `0.0 ... 100.0`. |
| `measured.Lab` | `array[float][3]` | Measured D50 $L^*a^*b^*$ ($L^* \in [0, 100]$, $a^*, b^* \in [-128, 127]$). |
| `measured.spectral` | `object` *(optional)* | Spectral reflection/emission data (omitted if instrument is colorimeter-only or `-n` passed). |
| `measured.spectral.bands` | `integer` | Number of spectral sample bands. |
| `measured.spectral.start_nm` | `float` | Starting wavelength in nanometres (e.g. `380.0` or `400.0`). |
| `measured.spectral.end_nm` | `float` | Ending wavelength in nanometres (e.g. `700.0` or `730.0`). |
| `measured.spectral.norm` | `float` | Normalization scale factor (typically `100.0`). |
| `measured.spectral.values` | `array[float]` | Array of spectral reflectance / radiance values per band. |
---
## 5. Integration Implementation Examples
### Node.js / Electron / TypeScript Integration
```typescript
import { spawn, ChildProcessWithoutNullStreams } from 'child_process';
import * as readline from 'readline';
export interface PatchColorEvent {
event: string;
row_id: string;
row_index: number;
total_rows: number;
patch_count: number;
patches: Array<{
id: string;
loc: string;
is_pad: boolean;
device: number[];
expected?: {
XYZ: [number, number, number];
Lab: [number, number, number];
};
measured: {
XYZ: [number, number, number];
Lab: [number, number, number];
spectral?: {
bands: number;
start_nm: number;
end_nm: number;
norm: number;
values: number[];
};
};
}>;
}
export class ChartreadRunner {
private process: ChildProcessWithoutNullStreams | null = null;
private readonly JSON_PREFIX = 'ROW_COLORS_JSON: ';
public startSession(
targetBasename: string,
onRowData: (data: PatchColorEvent) => void,
onConsoleMessage: (msg: string) => void,
onError: (err: string) => void
): void {
// Spawn isolated process over standard pipes (No library linking)
this.process = spawn('chartread', ['-u', targetBasename]);
// Parse stdout line by line
const rlOut = readline.createInterface({ input: this.process.stdout });
rlOut.on('line', (line: string) => {
const trimmed = line.trim();
if (trimmed.startsWith(this.JSON_PREFIX)) {
try {
const jsonStr = trimmed.substring(this.JSON_PREFIX.length);
const payload: PatchColorEvent = JSON.parse(jsonStr);
onRowData(payload);
} catch (err) {
onError(`Failed to parse JSON row payload: ${err}`);
}
} else if (trimmed.length > 0) {
onConsoleMessage(trimmed);
}
});
// Capture stderr for warnings / errors
const rlErr = readline.createInterface({ input: this.process.stderr });
rlErr.on('line', (errLine: string) => {
onError(errLine);
});
this.process.on('close', (code) => {
onConsoleMessage(`chartread exited with code ${code}`);
this.process = null;
});
}
/**
* Send user keyboard triggers or confirmations to chartread (e.g. Spacebar or Enter)
*/
public sendInput(input: string): void {
if (this.process && this.process.stdin.writable) {
this.process.stdin.write(input);
}
}
/**
* Abort measurement session
*/
public abort(): void {
if (this.process) {
this.sendInput('q\n'); // Send standard quit character
setTimeout(() => {
if (this.process) {
this.process.kill('SIGTERM');
}
}, 500);
}
}
}
```
---
### Python Subprocess Integration
```python
import subprocess
import json
import threading
class ChartreadClient:
JSON_PREFIX = "ROW_COLORS_JSON: "
def __init__(self, target_basename: str, on_row_callback, on_status_callback):
self.target_basename = target_basename
self.on_row_callback = on_row_callback
self.on_status_callback = on_status_callback
self.process = None
def start(self):
# Arms-length subprocess invocation maintaining AGPL isolation
self.process = subprocess.Popen(
["chartread", "-u", self.target_basename],
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.PIPE,
text=True,
bufsize=1 # Line buffered
)
threading.Thread(target=self._read_stdout, daemon=True).start()
threading.Thread(target=self._read_stderr, daemon=True).start()
def _read_stdout(self):
for line in iter(self.process.stdout.readline, ''):
line_str = line.strip()
if line_str.startswith(self.JSON_PREFIX):
payload_str = line_str[len(self.JSON_PREFIX):]
try:
data = json.loads(payload_str)
self.on_row_callback(data)
except json.JSONDecodeError as ex:
print(f"JSON decode error: {ex}")
elif line_str:
self.on_status_callback(line_str)
def _read_stderr(self):
for line in iter(self.process.stderr.readline, ''):
if line.strip():
print(f"[chartread stderr] {line.strip()}")
def send_key(self, key: str):
if self.process and self.process.stdin:
self.process.stdin.write(f"{key}\n")
self.process.stdin.flush()
def terminate(self):
if self.process:
self.process.terminate()
```
---
## 6. Real-Time UI Visualisation Best Practices
1. **Rendering Device Colors**:
* For RGB targets: scale `device[0..2]` from $0..100$ to $0..255$ (`rgb(r%, g%, b%)`).
* For CMYK targets: use device simulation or convert `measured.Lab` / `expected.Lab` to sRGB for color swatch display.
2. **Delta E Calculation ($\Delta E_{00}$ or $\Delta E_{ab}$)**:
* When `expected.Lab` and `measured.Lab` are both present, calculate the color difference $\Delta E$.
* The basic Euclidean distance ($\Delta E_{ab}$) is simple to implement directly:
$$\Delta E_{ab} = \sqrt{(L_m^* - L_e^*)^2 + (a_m^* - a_e^*)^2 + (b_m^* - b_e^*)^2}$$
* **Recommendation**: For professional color work, use the modern $\Delta E_{00}$ (CIEDE2000) formula. Due to its complexity, it is highly recommended to use an established color math library (e.g., `colorjs.io` in JavaScript, or `colormath` in Python) rather than implementing it from scratch.
* Display green/amber/red indicator lights next to each patch in real-time based on your accepted $\Delta E$ tolerance.
3. **Handling Alignment / Spacer Patches (`is_pad == true`)**:
* Omit `is_pad == true` patches from quality score computations, or render them with dashed neutral borders to maintain layout grid accuracy without skewing statistics.