mirror of
https://github.com/storytold/photocraft.git
synced 2026-10-29 02:32:01 +01:00
* Raw: measure the Nikon D90 image area (4288x2848 at (10, 10)) Aligning the decoded D90 sensor plane with the camera's own JPEG puts the image at (10, 10) in the 4352x2868 sensor plane, giving the camera's 4288x2848 frame (10-pixel margins; the right border reads the camera's flat 255 from column 4310). Add the measured ImageArea so a D90 NEF opens on the camera's frame instead of the uncropped plane, and make a measured profile leave a file that carries its own colour calibration (a DNG) untouched. * Codecs: open Cineon (.cin) film frames read-only Cineon is the 10-bit logarithmic film-scan format DPX grew from. Read it from the same Kodak header family: the generic header (big-endian, orientation at 192, channel count at 193, the channel-1 bits/dimensions), one or three channels, three 10-bit samples packed into each 32-bit word on a word boundary. The 10-bit code values open as U16 (round(c x 65535 / 1023)); the file's log black/white points are a display choice and are not applied. A depth other than 10 bits or a channel count other than 1/3 is Unsupported; truncation errors rather than panicking. Read-only (no Cineon writer), so .cin joins the open filters and Cineon read closes part of #1049's G10 gap. * PSD: write tiff blocks without copies, inflate merged ZIP once - ImageSourceData::to_bytes writes the global mask and global blocks straight from the model with 4-byte alignment instead of cloning each block (data included) to set its padding. Same bytes. - The tiff transcoder's nested regions and blocks write into the parent's buffer (taken and handed back, error or not) instead of building their own output and copying it up per nesting level. Bodies that fail or are not transcoded are truncated before being copied as stored or dropped. Same bytes. - PsdFile::from_bytes_and_merged decodes the merged image with the parse when asked: a ZIP stream is inflated once (the decode's stream check is the parse's); the parse-only check runs only when the decode fails, to keep from_bytes' errors. PSD import uses it with merged_needed. - PsdBuilder::composite documents that Gray/CMYK composites stay straight. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: measure a thin hole by its thickness, not its bounds (#1366) Removing long, thin objects (coiled guitar strings, wires) left their outline as darker and lighter blotches and copied pieces of other strings into the fill. The pyramid shrank until the hole's bounding box was four patches across: for a string across the image that is a level where the hole has swallowed its surroundings, so the matches found there copy whatever looks like the blur, and the finer levels (refine-only for wide bounds) keep them. A hole at most a quarter as thick as its bounds are long now counts as thin: the pyramid stops while it is still about two patches thick, and that level starts from a membrane across it (over a few pixels, the surroundings themselves) instead of the onion peel, whose exhaustive search is what made a shallow pyramid unaffordable. Compact holes are unchanged bit for bit. Refs #1366 * perf(compose,gpu): effect caches reserve tile addresses instead of pinning layers Effect map cache entries kept a clone of their layer (a deep clone for groups) so the tile addresses the cache key hashes couldn't be reused. That kept an edited or deleted layer's pixels alive, uncounted, until the entry was evicted. Keep a Weak to each hashed tile instead: it reserves the address without keeping the pixels. The GPU compositor's styled-group pins and shape-source tile snapshots do the same. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * fix(history): count snapshot-only tiles against the history budget History panel snapshots outlived every budget: a Merged Layers snapshot is a full flattened copy that neither the history budget, the GPU budget's document bytes nor Purge accounted for. Trim now counts the tiles only snapshots hold as fixed cost, the GPU budget includes them, and Purge > Histories no longer reports tiles a kept snapshot still holds as freed. Part of #4530. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> * Content-Aware Fill: every part of a thin selection keeps its surroundings (#1366) Two more causes of ghosts when removing strings and the small knobs selected with them: * Auto sampling summarises the selection's surroundings by a few clusters. Around a long selection those come from the strings' surroundings, so a small part elsewhere (a tuning knob in a differently coloured corner) lost its own surroundings and was filled from far away in another colour. Around a thin selection the Auto area now always reaches as far as the selection is thick. * The membrane that starts a thin hole now starts a patch radius out, past the anti-aliased fringe or halo that a selection drawn close to the object leaves outside it. Refs #1366 * Content-Aware Fill: peel a thin hole where that stays affordable (#1366) Starting every thin hole from a membrane smoothed Spot Healing on film grain (spot_healing_keeps_film_grain_through_overlapping_strokes: grain 9.0 against 12.1 around). The onion peel copies the grain; it was only given up because its exhaustive search is too slow on the big level a long thin hole stops at. It now starts the thin hole whenever hole cells times sources stay within 2^28 comparisons, and the membrane only beyond. Refs #1366 * Select Subject: keep subject parts coloured like the backdrop, leave enclosed gaps open Users on #2788 reported Select Subject dropping clothes that are close in colour to a plain wall and bridging the gap between an arm and the torso. Both come from the same place: the saliency pass labels low-contrast parts of the subject as background, the background colour model then learns them and explains them away, and the final blind hole fill closes every enclosed gap up to 5 % of the mask. Two changes in crates/algo: - A second GrabCut pass whose background model only knows the backdrop. A colour model fitted to the image frame (minus a margin around the salient blob, in case the subject runs out of the picture) marks every pixel it explains as probable background; the blob and whatever the backdrop cannot account for start as probable foreground. Only components that touch the first pass's result survive. - Hole filling that consults the colour models: specks (<= 0.2 % of the mask) always fill, larger holes up to 5 % fill only when their pixels fit the foreground model better than the background's. Tests: two regressions (a cream-shouldered figure on a near-white wall keeps its shoulders; a ring keeps its backdrop-coloured hole open), plus ignored timing and picture-dump helpers. The 6 MP first run goes from about 100 ms to about 200 ms on one core; the P29 repeat path is untouched. * Keyboard shortcut sets: Set menu, New Set, Save, Delete, .kys Export and Summarize (K2) Edit › Keyboard Shortcuts gets Photoshop's set management. A set is a saved copy of the bindings (Preferences.shortcutSets, the one in use in shortcutSet; "" is PhotoCraft Defaults), and the Set menu shows "(modified)" when the bindings differ from it. New Set…, Save and Delete wait for OK like the rest of the dialog. Export… writes the dialog's bindings as a .kys file (commands then tools, Photoshop's macOS key spelling), each row carrying our id as pc-id so PhotoCraft imports its own sets back exactly, whatever the label; Summarize… writes Photoshop's HTML list of every bound command by menu. edit.keyboardShortcuts takes useSet (before the call's edits), saveSet, deleteSet, sets and activeSet (after them), exportKys and summarize; every reply reports the sets, the set in use and whether it is modified. Bad names, keys or counts are errors that change nothing. * .kys import: tool keys and sets saved on Windows (K3) Photoshop's <tool> rows now import: each matches our tool by name (our tool names are Photoshop's) or by pc-id in sets PhotoCraft exported, and a letter other than the tool's own becomes its override, while its own letter keeps the tool cycling with the others (#5036). A set saved on Windows spells the primary modifier Ctrl where a Mac set writes Cmd and keeps Control for the Control key. A set with no Cmd or Opt anywhere but some Ctrl is read as a Windows set, its Ctrl as our Cmd: Ctrl+L is the Cmd+L default, not a Control-key override that would also mean Control on a Mac. The import reports `windows`. * cargo fmt the extension lists Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com> --------- Co-authored-by: Vikram Bhaskaran <vikram.bhaskaran@outlook.com> Co-authored-by: vangel76 <vangel1976@googlemail.com> Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com> Co-authored-by: Gert Pellin <gert@pellin.be> Co-authored-by: Brandon Thomas <echelon@gmail.com> Co-authored-by: Diego Chagas <drochachagas@gmail.com> Co-authored-by: Tanish Manku <tmanku.dev@gmail.com>
1279 lines
52 KiB
Rust
1279 lines
52 KiB
Rust
//! Image › Analysis: measurement scale, data points, Record Measurements and the Measurement
|
||
//! Log, the Ruler tool (with protractor and Straighten Layer), the Count tool and Place Scale
|
||
//! Marker.
|
||
//!
|
||
//! The scale, count groups and ruler line are document data ([`photocraft_doc::Measurement`],
|
||
//! saved in `.pcraft`; the scale also as PSD resource 1074). The Measurement Log and the data
|
||
//! point choices are session-wide, as in Photoshop.
|
||
//!
|
||
//! Selection measurements follow Photoshop's definitions: the selection (coverage ≥ 50 %) is
|
||
//! split into 8-connected features, each recorded with a summary row first. Area is the pixel
|
||
//! count, perimeter the length of the marching-squares contour through the pixel-centre grid
|
||
//! (exact along straight edges, √2 per diagonal step), circularity 4π·area/perimeter², gray
|
||
//! values the luminosity (0.30 R + 0.59 G + 0.11 B) of the composite in the document's value
|
||
//! range (0–255 at 8 bits, 0–32768 at 16 bits, 0–1 at 32 bits), integrated density area × mean.
|
||
//! Lengths and areas are scaled by the measurement scale.
|
||
|
||
use std::sync::Arc;
|
||
|
||
use photocraft_color::{Color, SampleType};
|
||
use photocraft_doc::{CountGroup, Document, Layer, MeasurementScale, Ruler};
|
||
use photocraft_geom::Rect;
|
||
use rayon::prelude::*;
|
||
use serde::{Deserialize, Serialize};
|
||
use serde_json::{Map, Value, json};
|
||
|
||
use crate::commands::CommandSpec;
|
||
use crate::{EngineError, Result, Session};
|
||
|
||
// ------------------------------------------------------------------ state
|
||
|
||
/// Data point columns: (key, Photoshop column header).
|
||
pub const COLUMNS: &[(&str, &str)] = &[
|
||
("label", "Label"),
|
||
("dateTime", "Date and Time"),
|
||
("document", "Document"),
|
||
("source", "Source"),
|
||
("scale", "Scale"),
|
||
("scaleUnits", "Scale Units"),
|
||
("scaleFactor", "Scale Factor"),
|
||
("count", "Count"),
|
||
("area", "Area"),
|
||
("perimeter", "Perimeter"),
|
||
("circularity", "Circularity"),
|
||
("height", "Height"),
|
||
("width", "Width"),
|
||
("grayMin", "Gray Value (Minimum)"),
|
||
("grayMax", "Gray Value (Maximum)"),
|
||
("grayMean", "Gray Value (Mean)"),
|
||
("grayMedian", "Gray Value (Median)"),
|
||
("integratedDensity", "Integrated Density"),
|
||
("histogram", "Histogram"),
|
||
("length", "Length"),
|
||
("angle", "Angle"),
|
||
];
|
||
|
||
const COMMON: &[&str] = &["label", "dateTime", "document", "source", "scale", "scaleUnits", "scaleFactor"];
|
||
const SELECTION_ONLY: &[&str] =
|
||
&["count", "area", "perimeter", "circularity", "height", "width", "grayMin", "grayMax", "grayMean", "grayMedian", "integratedDensity", "histogram"];
|
||
const RULER_ONLY: &[&str] = &["length", "angle"];
|
||
const COUNT_ONLY: &[&str] = &["count"];
|
||
|
||
/// Data points a measurement source can record.
|
||
pub fn available(source: &str) -> Vec<&'static str> {
|
||
let extra = match source {
|
||
"selection" => SELECTION_ONLY,
|
||
"ruler" => RULER_ONLY,
|
||
_ => COUNT_ONLY,
|
||
};
|
||
COMMON.iter().chain(extra).copied().collect()
|
||
}
|
||
|
||
/// Image › Analysis › Select Data Points: what Record Measurements writes per source.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct DataPoints {
|
||
pub selection: Vec<String>,
|
||
pub ruler: Vec<String>,
|
||
pub count: Vec<String>,
|
||
}
|
||
|
||
impl Default for DataPoints {
|
||
fn default() -> Self {
|
||
let all = |s| available(s).into_iter().map(String::from).collect();
|
||
Self { selection: all("selection"), ruler: all("ruler"), count: all("count") }
|
||
}
|
||
}
|
||
|
||
impl DataPoints {
|
||
fn of(&self, source: &str) -> &[String] {
|
||
match source {
|
||
"selection" => &self.selection,
|
||
"ruler" => &self.ruler,
|
||
_ => &self.count,
|
||
}
|
||
}
|
||
fn of_mut(&mut self, source: &str) -> &mut Vec<String> {
|
||
match source {
|
||
"selection" => &mut self.selection,
|
||
"ruler" => &mut self.ruler,
|
||
_ => &mut self.count,
|
||
}
|
||
}
|
||
}
|
||
|
||
/// One Measurement Log row.
|
||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||
pub struct LogRow {
|
||
pub id: u64,
|
||
/// Data point values by key (see [`COLUMNS`]).
|
||
pub values: Map<String, Value>,
|
||
}
|
||
|
||
/// Session-wide analysis state (`Session::analysis`).
|
||
#[derive(Clone, Debug, Default)]
|
||
pub struct AnalysisState {
|
||
pub log: Vec<LogRow>,
|
||
next_row: u64,
|
||
/// Counter for "Measurement N" labels.
|
||
next_measurement: u64,
|
||
pub data_points: DataPoints,
|
||
}
|
||
|
||
// ------------------------------------------------------------------ helpers
|
||
|
||
fn bad(cmd: &str, msg: impl Into<String>) -> EngineError {
|
||
EngineError::BadParams { cmd: cmd.into(), msg: msg.into() }
|
||
}
|
||
|
||
fn has_doc(s: &Session) -> std::result::Result<(), String> {
|
||
s.active().map(|_| ()).ok_or_else(|| "no document open".into())
|
||
}
|
||
|
||
fn always(_: &Session) -> std::result::Result<(), String> {
|
||
Ok(())
|
||
}
|
||
|
||
fn has_ruler(s: &Session) -> std::result::Result<(), String> {
|
||
has_doc(s)?;
|
||
s.active().and_then(|d| d.doc.measurement.ruler).map(|_| ()).ok_or_else(|| "no ruler line".into())
|
||
}
|
||
|
||
fn has_log(s: &Session) -> std::result::Result<(), String> {
|
||
if s.analysis.log.is_empty() { Err("the Measurement Log is empty".into()) } else { Ok(()) }
|
||
}
|
||
|
||
fn point(v: Option<&Value>) -> Option<[f64; 2]> {
|
||
let a = v?.as_array()?;
|
||
Some([a.first()?.as_f64()?, a.get(1)?.as_f64()?])
|
||
}
|
||
|
||
fn xy(p: &Value) -> Option<[f64; 2]> {
|
||
Some([p.get("x")?.as_f64()?, p.get("y")?.as_f64()?])
|
||
}
|
||
|
||
fn doc(s: &Session) -> Result<Arc<Document>> {
|
||
Ok(s.active().ok_or(EngineError::NoDocument)?.doc.clone())
|
||
}
|
||
|
||
/// Change the document without a history step (ruler line: view-like state that is still saved
|
||
/// with the document). A clean document stays clean.
|
||
fn set_quiet(s: &mut Session, f: impl FnOnce(&mut Document)) -> Result<()> {
|
||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||
let mut d = (*st.doc).clone();
|
||
f(&mut d);
|
||
st.doc = Arc::new(d);
|
||
let clean = st.saved_revision == st.revision;
|
||
st.revision += 1;
|
||
if clean {
|
||
st.saved_revision = st.revision;
|
||
}
|
||
st.last_damage = Some(Rect::EMPTY);
|
||
Ok(())
|
||
}
|
||
|
||
/// Run several commands as one history step labelled `label`. On failure the document goes back
|
||
/// as it was and the journal forgets the commands `f` ran, so an action being recorded does not
|
||
/// replay an edit that was taken back.
|
||
pub(crate) fn compound<R>(s: &mut Session, label: &str, f: impl FnOnce(&mut Session) -> Result<R>) -> Result<R> {
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let (before, history, prior) = (st.doc.clone(), st.history.clone(), st.layer_target());
|
||
// An inner command may select a layer that disappears when the compound rolls back.
|
||
// Selection, channel targeting and coalescing must roll back with the document (#4975).
|
||
let anchor = st.layer_anchor;
|
||
let channel_view = st.channel_view.clone();
|
||
let coalesce = st.coalesce.clone();
|
||
let was_clean = !st.is_dirty();
|
||
let journal = s.journal.len();
|
||
// Color Sampler points are view state a step may move (crop, rotation): roll them back too, so
|
||
// a failed second step can't leave them pinned to pixels the restored document no longer has.
|
||
let samplers = st.color_samplers.clone();
|
||
let r = f(s);
|
||
if r.is_err() {
|
||
s.journal.truncate(journal);
|
||
}
|
||
let st = s.active_mut().ok_or(EngineError::NoDocument)?;
|
||
st.history = history;
|
||
let result = match r {
|
||
Ok(v) => {
|
||
// As in `Session::edit`: undo targets what was selected just before the step (#1356).
|
||
st.history.set_current_layers(prior);
|
||
st.history.record(label, before, st.layer_target());
|
||
st.trim_history();
|
||
st.coalesce = None;
|
||
Ok(v)
|
||
}
|
||
Err(e) => {
|
||
st.doc = before;
|
||
st.active_layer = prior.active;
|
||
st.selected_layers = prior.selected;
|
||
st.layer_anchor = anchor;
|
||
st.channel_view = channel_view;
|
||
st.coalesce = coalesce;
|
||
st.color_samplers = samplers;
|
||
st.revision += 1;
|
||
// A failed compound does not make an otherwise untouched document dirty.
|
||
if was_clean {
|
||
st.saved_revision = st.revision;
|
||
}
|
||
st.last_damage = None;
|
||
Err(e)
|
||
}
|
||
};
|
||
if result.is_err() {
|
||
// The app-wide tool colours/mask mode follow the restored channel target.
|
||
s.sync_tools_mask_target();
|
||
}
|
||
result
|
||
}
|
||
|
||
/// Current time as ISO 8601 UTC (`2026-10-01T12:34:56Z`); empty on the web (no clock).
|
||
pub fn now_iso() -> String {
|
||
#[cfg(not(target_arch = "wasm32"))]
|
||
{
|
||
let secs = std::time::SystemTime::now().duration_since(std::time::UNIX_EPOCH).map(|d| d.as_secs() as i64).unwrap_or(0);
|
||
let (days, rem) = (secs.div_euclid(86_400), secs.rem_euclid(86_400));
|
||
// Civil date from days since 1970-01-01 (Howard Hinnant's algorithm).
|
||
let z = days + 719_468;
|
||
let era = z.div_euclid(146_097);
|
||
let doe = z - era * 146_097;
|
||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||
let mp = (5 * doy + 2) / 153;
|
||
let d = doy - (153 * mp + 2) / 5 + 1;
|
||
let m = if mp < 10 { mp + 3 } else { mp - 9 };
|
||
let y = yoe + era * 400 + i64::from(m <= 2);
|
||
format!("{y:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}Z", rem / 3600, rem % 3600 / 60, rem % 60)
|
||
}
|
||
#[cfg(target_arch = "wasm32")]
|
||
{
|
||
String::new()
|
||
}
|
||
}
|
||
|
||
fn round4(v: f64) -> f64 {
|
||
(v * 10_000.0).round() / 10_000.0
|
||
}
|
||
|
||
// ------------------------------------------------------------------ scale
|
||
|
||
fn set_scale(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "image.analysis.setMeasurementScale";
|
||
let cur = doc(s)?.measurement.scale.clone();
|
||
let preset = p.get("preset").and_then(Value::as_str).unwrap_or(if p.as_object().is_none_or(|o| o.is_empty()) { "query" } else { "custom" });
|
||
let next = match preset {
|
||
"query" => return Ok(scale_json(&cur)),
|
||
"default" => MeasurementScale::default(),
|
||
"custom" => {
|
||
let pl = p.get("pixelLength").and_then(Value::as_f64).unwrap_or(cur.pixel_length);
|
||
let ll = p.get("logicalLength").and_then(Value::as_f64).unwrap_or(cur.logical_length);
|
||
let units = p.get("units").or_else(|| p.get("logicalUnits")).and_then(Value::as_str).map(str::to_string).unwrap_or(cur.units.clone());
|
||
if !(pl > 0.0 && pl.is_finite() && ll > 0.0 && ll.is_finite()) {
|
||
return Err(bad(CMD, "pixelLength and logicalLength must be positive"));
|
||
}
|
||
if units.trim().is_empty() {
|
||
return Err(bad(CMD, "units must not be empty"));
|
||
}
|
||
MeasurementScale { pixel_length: pl, logical_length: ll, units }
|
||
}
|
||
other => return Err(bad(CMD, format!("unknown preset `{other}` (default|custom)"))),
|
||
};
|
||
if next != cur {
|
||
let n = next.clone();
|
||
s.edit("Set Measurement Scale", move |d, _| {
|
||
d.measurement.scale = n;
|
||
Ok(())
|
||
})?;
|
||
}
|
||
Ok(scale_json(&next))
|
||
}
|
||
|
||
fn scale_json(sc: &MeasurementScale) -> Value {
|
||
json!({"pixelLength": sc.pixel_length, "logicalLength": sc.logical_length, "units": sc.units, "factor": sc.factor(), "text": sc.describe(), "default": sc.is_default()})
|
||
}
|
||
|
||
// ------------------------------------------------------------------ data points
|
||
|
||
fn select_data_points(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "image.analysis.selectDataPoints";
|
||
let mut next = if p.get("reset").and_then(Value::as_bool) == Some(true) { DataPoints::default() } else { s.analysis.data_points.clone() };
|
||
for source in ["selection", "ruler", "count"] {
|
||
let avail = available(source);
|
||
let Some(v) = p.get(source) else { continue };
|
||
let list = next.of_mut(source);
|
||
match v {
|
||
// A full list of keys…
|
||
Value::Array(a) => {
|
||
let mut keys = Vec::new();
|
||
for k in a {
|
||
let k = k.as_str().ok_or_else(|| bad(CMD, format!("`{source}` entries must be strings")))?;
|
||
if !avail.contains(&k) {
|
||
return Err(bad(CMD, format!("`{k}` is not a {source} data point ({})", avail.join("|"))));
|
||
}
|
||
keys.push(k.to_string());
|
||
}
|
||
*list = keys;
|
||
}
|
||
// …or {key: on/off} changes.
|
||
Value::Object(o) => {
|
||
for (k, on) in o {
|
||
if !avail.contains(&k.as_str()) {
|
||
return Err(bad(CMD, format!("`{k}` is not a {source} data point ({})", avail.join("|"))));
|
||
}
|
||
let on = on.as_bool().ok_or_else(|| bad(CMD, format!("`{source}.{k}` must be a bool")))?;
|
||
list.retain(|x| x != k);
|
||
if on {
|
||
list.push(k.clone());
|
||
}
|
||
}
|
||
}
|
||
_ => return Err(bad(CMD, format!("`{source}` must be a list of keys or an object of bools"))),
|
||
}
|
||
// Keep the column order.
|
||
list.sort_by_key(|k| COLUMNS.iter().position(|c| c.0 == k));
|
||
}
|
||
s.analysis.data_points = next;
|
||
Ok(
|
||
json!({"dataPoints": s.analysis.data_points, "available": {"selection": available("selection"), "ruler": available("ruler"), "count": available("count")}, "columns": COLUMNS.iter().map(|c| json!({"key": c.0, "name": c.1})).collect::<Vec<_>>()}),
|
||
)
|
||
}
|
||
|
||
// ------------------------------------------------------------------ selection measurements
|
||
|
||
/// Measurements of one selection feature (or the summary of all).
|
||
#[derive(Clone, Debug, Default, PartialEq)]
|
||
pub struct Feature {
|
||
pub area: f64,
|
||
pub perimeter: f64,
|
||
pub bounds: Rect,
|
||
pub gray_min: f64,
|
||
pub gray_max: f64,
|
||
pub gray_mean: f64,
|
||
pub gray_median: f64,
|
||
pub histogram: Vec<u64>,
|
||
}
|
||
|
||
impl Feature {
|
||
pub fn circularity(&self) -> f64 {
|
||
if self.perimeter > 0.0 { (4.0 * std::f64::consts::PI * self.area / (self.perimeter * self.perimeter)).min(1.0) } else { 0.0 }
|
||
}
|
||
}
|
||
|
||
/// Union-find root with path halving.
|
||
fn root(parent: &mut [u32], mut i: u32) -> u32 {
|
||
while parent[i as usize] != i {
|
||
parent[i as usize] = parent[parent[i as usize] as usize];
|
||
i = parent[i as usize];
|
||
}
|
||
i
|
||
}
|
||
|
||
/// Gray value range of a depth: what a "gray value" of 1.0 reads as.
|
||
fn gray_range(depth: SampleType) -> f64 {
|
||
match depth {
|
||
SampleType::U8 => 255.0,
|
||
SampleType::U16 => 32768.0,
|
||
_ => 1.0,
|
||
}
|
||
}
|
||
|
||
/// Split `mask` (w×h, true = selected) into 8-connected features and measure each against the
|
||
/// gray image `gray` (0–1). Returns features in scan order of their first pixel; `origin` offsets
|
||
/// the reported bounds.
|
||
pub fn measure_features(mask: &[bool], gray: &[f32], w: usize, h: usize, origin: (i32, i32), range: f64) -> Vec<Feature> {
|
||
// Two-pass connected components.
|
||
let mut label = vec![u32::MAX; w * h];
|
||
let mut parent: Vec<u32> = Vec::new();
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let i = y * w + x;
|
||
if !mask[i] {
|
||
continue;
|
||
}
|
||
let mut best = u32::MAX;
|
||
let mut nbrs = [u32::MAX; 4];
|
||
if x > 0 {
|
||
nbrs[0] = label[i - 1];
|
||
}
|
||
if y > 0 {
|
||
nbrs[1] = label[i - w];
|
||
if x > 0 {
|
||
nbrs[2] = label[i - w - 1];
|
||
}
|
||
if x + 1 < w {
|
||
nbrs[3] = label[i - w + 1];
|
||
}
|
||
}
|
||
for &n in &nbrs {
|
||
if n != u32::MAX {
|
||
let r = root(&mut parent, n);
|
||
best = best.min(r);
|
||
}
|
||
}
|
||
if best == u32::MAX {
|
||
best = parent.len() as u32;
|
||
parent.push(best);
|
||
} else {
|
||
for &n in &nbrs {
|
||
if n != u32::MAX {
|
||
let r = root(&mut parent, n);
|
||
if r != best {
|
||
let (a, b) = (r.min(best), r.max(best));
|
||
parent[b as usize] = a;
|
||
best = a;
|
||
}
|
||
}
|
||
}
|
||
}
|
||
label[i] = best;
|
||
}
|
||
}
|
||
// Compact roots → feature indices in order of first appearance.
|
||
let mut index = vec![u32::MAX; parent.len()];
|
||
let mut n = 0u32;
|
||
for l in label.iter_mut() {
|
||
if *l == u32::MAX {
|
||
continue;
|
||
}
|
||
let r = root(&mut parent, *l) as usize;
|
||
if index[r] == u32::MAX {
|
||
index[r] = n;
|
||
n += 1;
|
||
}
|
||
*l = index[r];
|
||
}
|
||
let n = n as usize;
|
||
// Perimeter: marching squares over the pixel-centre grid (padded with unselected pixels),
|
||
// per row band in parallel, each cell's length attributed to the feature of a pixel in it.
|
||
let at = |x: isize, y: isize| -> u32 { if x < 0 || y < 0 || x >= w as isize || y >= h as isize { u32::MAX } else { label[y as usize * w + x as usize] } };
|
||
const HALF_DIAG: f64 = std::f64::consts::FRAC_1_SQRT_2;
|
||
let per: Vec<f64> = (-1..h as isize)
|
||
.into_par_iter()
|
||
.fold(
|
||
|| vec![0.0f64; n],
|
||
|mut acc, y| {
|
||
for x in -1..w as isize {
|
||
let c = [at(x, y), at(x + 1, y), at(x + 1, y + 1), at(x, y + 1)];
|
||
let inside = c.iter().filter(|v| **v != u32::MAX).count();
|
||
let len = match inside {
|
||
1 | 3 => HALF_DIAG,
|
||
2 if (c[0] != u32::MAX) == (c[2] != u32::MAX) => 2.0 * HALF_DIAG,
|
||
2 => 1.0,
|
||
_ => 0.0,
|
||
};
|
||
if len > 0.0
|
||
&& let Some(f) = c.iter().find(|v| **v != u32::MAX)
|
||
{
|
||
acc[*f as usize] += len;
|
||
}
|
||
}
|
||
acc
|
||
},
|
||
)
|
||
.reduce(
|
||
|| vec![0.0f64; n],
|
||
|mut a, b| {
|
||
a.iter_mut().zip(b).for_each(|(x, y)| *x += y);
|
||
a
|
||
},
|
||
);
|
||
// Area, bounds and gray values.
|
||
let mut feats: Vec<Feature> =
|
||
(0..n).map(|_| Feature { bounds: Rect::EMPTY, gray_min: f64::MAX, gray_max: f64::MIN, histogram: vec![0; 256], ..Default::default() }).collect();
|
||
let mut sums = vec![0.0f64; n];
|
||
let mut values: Vec<Vec<u16>> = vec![Vec::new(); n];
|
||
for y in 0..h {
|
||
for x in 0..w {
|
||
let l = label[y * w + x];
|
||
if l == u32::MAX {
|
||
continue;
|
||
}
|
||
let f = &mut feats[l as usize];
|
||
f.area += 1.0;
|
||
let (px, py) = (x as i32 + origin.0, y as i32 + origin.1);
|
||
let r = Rect::new(px, py, px + 1, py + 1);
|
||
f.bounds = if f.bounds.is_empty() { r } else { f.bounds.union(&r) };
|
||
let g = f64::from(gray[y * w + x]);
|
||
f.gray_min = f.gray_min.min(g);
|
||
f.gray_max = f.gray_max.max(g);
|
||
sums[l as usize] += g;
|
||
f.histogram[(g.clamp(0.0, 1.0) * 255.0).round() as usize] += 1;
|
||
values[l as usize].push((g.clamp(0.0, 1.0) * 65535.0).round() as u16);
|
||
}
|
||
}
|
||
for (i, f) in feats.iter_mut().enumerate() {
|
||
f.perimeter = per[i];
|
||
f.gray_mean = sums[i] / f.area.max(1.0) * range;
|
||
f.gray_min *= range;
|
||
f.gray_max *= range;
|
||
f.gray_median = median(&mut values[i]) * range;
|
||
}
|
||
feats
|
||
}
|
||
|
||
fn median(v: &mut [u16]) -> f64 {
|
||
if v.is_empty() {
|
||
return 0.0;
|
||
}
|
||
let mid = v.len() / 2;
|
||
let (_, m, _) = v.select_nth_unstable(mid);
|
||
let m = f64::from(*m);
|
||
let m = if v.len().is_multiple_of(2) { (m + f64::from(*v[..mid].iter().max().unwrap_or(&0))) / 2.0 } else { m };
|
||
m / 65535.0
|
||
}
|
||
|
||
/// Summary over features (area-weighted mean, pooled median).
|
||
fn summary(feats: &[Feature], all_values_median: f64) -> Feature {
|
||
let area: f64 = feats.iter().map(|f| f.area).sum();
|
||
let mut hist = vec![0u64; 256];
|
||
let mut b = Rect::EMPTY;
|
||
for f in feats {
|
||
hist.iter_mut().zip(&f.histogram).for_each(|(a, v)| *a += v);
|
||
b = if b.is_empty() { f.bounds } else { b.union(&f.bounds) };
|
||
}
|
||
Feature {
|
||
area,
|
||
perimeter: feats.iter().map(|f| f.perimeter).sum(),
|
||
bounds: b,
|
||
gray_min: feats.iter().map(|f| f.gray_min).fold(f64::MAX, f64::min),
|
||
gray_max: feats.iter().map(|f| f.gray_max).fold(f64::MIN, f64::max),
|
||
gray_mean: feats.iter().map(|f| f.gray_mean * f.area).sum::<f64>() / area.max(1.0),
|
||
gray_median: all_values_median,
|
||
histogram: hist,
|
||
}
|
||
}
|
||
|
||
/// Largest area Record Measurements handles in one pass: the gray render alone is 16 bytes per
|
||
/// pixel, and the label buffer and mask come on top (#933).
|
||
pub const MAX_MEASURE_PIXELS: u64 = 400_000_000;
|
||
|
||
/// Selection mask (coverage ≥ 0.5) over `r` (`n` pixels), or every pixel when there is no selection.
|
||
fn selection_mask(d: &Document, r: Rect, n: usize) -> Vec<bool> {
|
||
match &d.selection {
|
||
None => vec![true; n],
|
||
Some(sel) => {
|
||
let c = sel.channels().max(1);
|
||
let v = sel.read_region(r);
|
||
v.chunks_exact(c).map(|p| p[0] >= 0.5).collect()
|
||
}
|
||
}
|
||
}
|
||
|
||
fn gray_image(d: &Document, r: Rect) -> Vec<f32> {
|
||
let buf = photocraft_compose::render(d, r);
|
||
buf.px.par_iter().map(|p| 0.30 * p[0] + 0.59 * p[1] + 0.11 * p[2]).collect()
|
||
}
|
||
|
||
fn selection_rows(d: &Document) -> Result<(Feature, Vec<Feature>)> {
|
||
let r = match &d.selection {
|
||
Some(sel) => sel.content_bounds().intersect(&d.bounds()),
|
||
None => d.bounds(),
|
||
};
|
||
if r.is_empty() {
|
||
return Err(EngineError::Other("the selection is empty".into()));
|
||
}
|
||
// A legal 65536×65536 document is 2^32 pixels: size everything from a checked count,
|
||
// and refuse what cannot be measured in one pass instead of overflowing (#933).
|
||
let n = u64::from(r.width()) * u64::from(r.height());
|
||
let Some(n) = (n <= MAX_MEASURE_PIXELS).then(|| usize::try_from(n).ok()).flatten() else {
|
||
return Err(EngineError::Other(format!(
|
||
"the area is too large to measure ({}×{} px; max {} megapixels): select a smaller region",
|
||
r.width(),
|
||
r.height(),
|
||
MAX_MEASURE_PIXELS / 1_000_000
|
||
)));
|
||
};
|
||
let (w, h) = (r.width() as usize, r.height() as usize);
|
||
let mask = selection_mask(d, r, n);
|
||
let gray = gray_image(d, r);
|
||
let range = gray_range(d.depth);
|
||
let feats = measure_features(&mask, &gray, w, h, (r.x0, r.y0), range);
|
||
if feats.is_empty() {
|
||
return Err(EngineError::Other("the selection is empty".into()));
|
||
}
|
||
let mut all: Vec<u16> = mask.iter().zip(&gray).filter(|(m, _)| **m).map(|(_, g)| (g.clamp(0.0, 1.0) * 65535.0).round() as u16).collect();
|
||
let med = median(&mut all) * range;
|
||
Ok((summary(&feats, med), feats))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ ruler
|
||
|
||
/// Ruler readout: X/Y (start), W/H, angle A and lengths L1/L2, as in the options bar.
|
||
pub fn ruler_info(r: &Ruler, sc: &MeasurementScale) -> Value {
|
||
let (dx, dy) = (r.end[0] - r.start[0], r.end[1] - r.start[1]);
|
||
let l1 = dx.hypot(dy);
|
||
let f = sc.factor();
|
||
match r.protractor {
|
||
None => {
|
||
// Counter-clockwise from the +x axis with y up, −180…180.
|
||
let a = (-dy).atan2(dx).to_degrees();
|
||
json!({"x": r.start[0], "y": r.start[1], "w": dx, "h": dy, "angle": round4(a), "l1": round4(l1), "l2": Value::Null, "length": round4(l1 * f), "units": sc.units, "protractor": false})
|
||
}
|
||
Some(q) => {
|
||
let (ex, ey) = (q[0] - r.start[0], q[1] - r.start[1]);
|
||
let l2 = ex.hypot(ey);
|
||
let a1 = (-dy).atan2(dx);
|
||
let a2 = (-ey).atan2(ex);
|
||
let mut a = (a1 - a2).to_degrees().abs();
|
||
if a > 180.0 {
|
||
a = 360.0 - a;
|
||
}
|
||
json!({"x": r.start[0], "y": r.start[1], "w": dx, "h": dy, "angle": round4(a), "l1": round4(l1), "l2": round4(l2), "length": round4(l1 * f), "units": sc.units, "protractor": true})
|
||
}
|
||
}
|
||
}
|
||
|
||
fn ruler_tool(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "image.analysis.rulerTool";
|
||
let d = doc(s)?;
|
||
if p.get("clear").and_then(Value::as_bool) == Some(true) {
|
||
set_quiet(s, |d| d.measurement.ruler = None)?;
|
||
return Ok(json!({"ruler": Value::Null}));
|
||
}
|
||
let start = point(p.get("start"));
|
||
let end = point(p.get("end"));
|
||
let mut r = d.measurement.ruler;
|
||
match (start, end) {
|
||
(Some(a), Some(b)) => {
|
||
let prot = match p.get("protractor") {
|
||
Some(Value::Null) | None => None,
|
||
Some(v) => Some(point(Some(v)).ok_or_else(|| bad(CMD, "`protractor` must be [x, y] or null"))?),
|
||
};
|
||
r = Some(Ruler { start: a, end: b, protractor: prot });
|
||
}
|
||
(None, None) => {
|
||
if let (Some(cur), Some(v)) = (r.as_mut(), p.get("protractor")) {
|
||
cur.protractor = if v.is_null() { None } else { Some(point(Some(v)).ok_or_else(|| bad(CMD, "`protractor` must be [x, y] or null"))?) };
|
||
}
|
||
}
|
||
_ => return Err(bad(CMD, "pass both `start` and `end`")),
|
||
}
|
||
for v in r.iter().flat_map(|r| [Some(r.start), Some(r.end), r.protractor]).flatten() {
|
||
if !v[0].is_finite() || !v[1].is_finite() {
|
||
return Err(bad(CMD, "coordinates must be finite"));
|
||
}
|
||
}
|
||
if r != d.measurement.ruler {
|
||
set_quiet(s, |d| d.measurement.ruler = r)?;
|
||
}
|
||
Ok(match r {
|
||
Some(r) => json!({"ruler": {"start": r.start, "end": r.end, "protractor": r.protractor}, "info": ruler_info(&r, &d.measurement.scale)}),
|
||
None => json!({"ruler": Value::Null}),
|
||
})
|
||
}
|
||
|
||
/// Largest axis-aligned rectangle of a `w`×`h` rectangle rotated by `a` radians.
|
||
fn inscribed(w: f64, h: f64, a: f64) -> (f64, f64) {
|
||
let (sa, ca) = (a.sin().abs(), a.cos().abs());
|
||
let long_w = w >= h;
|
||
let (long, short) = if long_w { (w, h) } else { (h, w) };
|
||
if short <= 2.0 * sa * ca * long || (sa - ca).abs() < 1e-10 {
|
||
let x = 0.5 * short;
|
||
if long_w { (x / sa, x / ca) } else { (x / ca, x / sa) }
|
||
} else {
|
||
let c2 = ca * ca - sa * sa;
|
||
((w * ca - h * sa) / c2, (h * ca - w * sa) / c2)
|
||
}
|
||
}
|
||
|
||
/// Clockwise rotation in degrees that turns the ruler line onto its nearest axis.
|
||
pub fn straighten_angle(r: &Ruler) -> f64 {
|
||
let (dx, dy) = (r.end[0] - r.start[0], r.end[1] - r.start[1]);
|
||
let a = (-dy).atan2(dx).to_degrees();
|
||
a - 90.0 * (a / 90.0).round()
|
||
}
|
||
|
||
fn straighten(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let d = doc(s)?;
|
||
let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line".into()))?;
|
||
let rot = straighten_angle(&r);
|
||
if rot.abs() < 1e-9 {
|
||
return Ok(json!({"angle": 0.0}));
|
||
}
|
||
let st = s.active().ok_or(EngineError::NoDocument)?;
|
||
let active_bg = st.active_layer.and_then(|id| d.layer(id)).is_some_and(|l| d.is_background(l.id));
|
||
let crop = p.get("crop").and_then(Value::as_bool).unwrap_or(active_bg || st.active_layer.is_none());
|
||
let (w0, h0) = (f64::from(d.size.width), f64::from(d.size.height));
|
||
compound(s, "Straighten", |s| {
|
||
if crop {
|
||
s.execute("image.rotation.arbitrary", json!({"angle": rot, "direction": "cw"}))?;
|
||
let nd = doc(s)?;
|
||
let (cw, ch) = inscribed(w0, h0, rot.to_radians());
|
||
let (nw, nh) = (f64::from(nd.size.width), f64::from(nd.size.height));
|
||
let (cw, ch) = (cw.floor().clamp(1.0, nw), ch.floor().clamp(1.0, nh));
|
||
s.execute("image.crop", json!({"x": ((nw - cw) / 2.0).round(), "y": ((nh - ch) / 2.0).round(), "width": cw, "height": ch}))?;
|
||
} else {
|
||
let (cx, cy) = (w0 / 2.0, h0 / 2.0);
|
||
let (sn, cs) = rot.to_radians().sin_cos();
|
||
// Clockwise on screen (y down) about the canvas centre.
|
||
let m = [cs, sn, -sn, cs, cx - (cs * cx - sn * cy), cy - (sn * cx + cs * cy)];
|
||
s.execute("edit.transform", json!({"matrix": m}))?;
|
||
}
|
||
// The ruler is cleared after straightening, as in Photoshop.
|
||
set_quiet(s, |d| d.measurement.ruler = None)
|
||
})?;
|
||
let nd = doc(s)?;
|
||
Ok(json!({"angle": round4(rot), "cropped": crop, "width": nd.size.width, "height": nd.size.height}))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ count
|
||
|
||
fn group_index(d: &Document, p: &Value) -> Option<usize> {
|
||
let i = p.get("group").and_then(Value::as_u64).map(|v| v as usize).unwrap_or(d.measurement.active_count_group);
|
||
(i < d.measurement.count_groups.len()).then_some(i)
|
||
}
|
||
|
||
fn color_of(p: &Value, key: &str) -> Option<Color> {
|
||
p.get(key)?;
|
||
let c = crate::commands::color_param(p, key, [1.0, 0.0, 0.0, 1.0]);
|
||
Some(Color::rgb(c[0], c[1], c[2]))
|
||
}
|
||
|
||
/// Photoshop's colours for new count groups, in order.
|
||
const GROUP_COLORS: [[f32; 3]; 6] = [[1.0, 0.0, 0.0], [0.0, 0.6, 1.0], [0.0, 0.8, 0.0], [1.0, 0.6, 0.0], [0.8, 0.0, 0.8], [1.0, 1.0, 0.0]];
|
||
|
||
fn new_group(d: &Document, name: Option<String>, color: Option<Color>) -> CountGroup {
|
||
let n = d.measurement.count_groups.len();
|
||
let c = GROUP_COLORS[n % GROUP_COLORS.len()];
|
||
CountGroup { name: name.unwrap_or_else(|| format!("Count Group {}", n + 1)), color: color.unwrap_or(Color::rgb(c[0], c[1], c[2])), ..Default::default() }
|
||
}
|
||
|
||
fn count_json(d: &Document) -> Value {
|
||
let m = &d.measurement;
|
||
json!({
|
||
"total": m.count_total(),
|
||
"activeGroup": m.active_count_group,
|
||
"groups": m.count_groups.iter().map(|g| json!({"name": g.name, "color": g.color.to_rgb(), "markerSize": g.marker_size, "labelSize": g.label_size, "visible": g.visible, "count": g.points.len(), "points": g.points})).collect::<Vec<_>>(),
|
||
})
|
||
}
|
||
|
||
fn count_tool(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
fn count_add(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let at = xy(p).or_else(|| point(p.get("point"))).ok_or_else(|| bad("count.add", "pass `x` and `y`"))?;
|
||
if !at[0].is_finite() || !at[1].is_finite() {
|
||
return Err(bad("count.add", "coordinates must be finite"));
|
||
}
|
||
let gi = p.get("group").and_then(Value::as_u64).map(|v| v as usize);
|
||
let n = s.edit("Count", |d, _| {
|
||
if d.measurement.count_groups.is_empty() {
|
||
let g = new_group(d, None, None);
|
||
d.measurement.count_groups.push(g);
|
||
d.measurement.active_count_group = 0;
|
||
}
|
||
let i = gi.unwrap_or(d.measurement.active_count_group).min(d.measurement.count_groups.len() - 1);
|
||
let g = &mut d.measurement.count_groups[i];
|
||
g.points.push(at);
|
||
Ok(g.points.len())
|
||
})?;
|
||
let mut r = count_json(doc(s)?.as_ref());
|
||
r["number"] = json!(n);
|
||
Ok(r)
|
||
}
|
||
|
||
/// Marker nearest `at` within `radius` px in group `g` (or any visible group).
|
||
fn nearest(d: &Document, at: [f64; 2], radius: f64, g: Option<usize>) -> Option<(usize, usize)> {
|
||
let mut best: Option<(f64, usize, usize)> = None;
|
||
for (gi, grp) in d.measurement.count_groups.iter().enumerate() {
|
||
if g.is_some_and(|x| x != gi) || !grp.visible {
|
||
continue;
|
||
}
|
||
for (pi, q) in grp.points.iter().enumerate() {
|
||
let dd = (q[0] - at[0]).hypot(q[1] - at[1]);
|
||
if dd <= radius && best.is_none_or(|b| dd < b.0) {
|
||
best = Some((dd, gi, pi));
|
||
}
|
||
}
|
||
}
|
||
best.map(|b| (b.1, b.2))
|
||
}
|
||
|
||
fn locate(s: &Session, p: &Value, cmd: &str) -> Result<(usize, usize)> {
|
||
let d = s.active().ok_or(EngineError::NoDocument)?.doc.clone();
|
||
if let Some(i) = p.get("index").and_then(Value::as_u64) {
|
||
let g = group_index(&d, p).ok_or_else(|| bad(cmd, "no such count group"))?;
|
||
if (i as usize) < d.measurement.count_groups[g].points.len() {
|
||
return Ok((g, i as usize));
|
||
}
|
||
return Err(bad(cmd, format!("no marker {i}")));
|
||
}
|
||
let at = xy(p).ok_or_else(|| bad(cmd, "pass `index` or `x`/`y`"))?;
|
||
let radius = p.get("radius").and_then(Value::as_f64).unwrap_or(6.0);
|
||
let g = p.get("group").and_then(Value::as_u64).map(|v| v as usize);
|
||
nearest(&d, at, radius, g).ok_or_else(|| EngineError::Other("no count marker there".into()))
|
||
}
|
||
|
||
fn count_remove(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let (g, i) = locate(s, p, "count.remove")?;
|
||
s.edit("Count", |d, _| {
|
||
d.measurement.count_groups[g].points.remove(i);
|
||
Ok(())
|
||
})?;
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
fn count_move(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let to = point(p.get("to")).ok_or_else(|| bad("count.move", "pass `to`: [x, y]"))?;
|
||
let (g, i) = locate(s, p, "count.move")?;
|
||
s.edit("Count", |d, _| {
|
||
d.measurement.count_groups[g].points[i] = to;
|
||
Ok(())
|
||
})?;
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
fn count_clear(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let d = doc(s)?;
|
||
let all = p.get("group").and_then(Value::as_str) == Some("all");
|
||
let g = if all { None } else { Some(group_index(&d, p).ok_or_else(|| bad("count.clear", "no such count group"))?) };
|
||
s.edit("Clear Count", |d, _| {
|
||
for (i, grp) in d.measurement.count_groups.iter_mut().enumerate() {
|
||
if g.is_none_or(|g| g == i) {
|
||
grp.points.clear();
|
||
}
|
||
}
|
||
Ok(())
|
||
})?;
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
fn count_new_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let name = p.get("name").and_then(Value::as_str).map(str::to_string);
|
||
let color = color_of(p, "color");
|
||
s.edit("New Count Group", |d, _| {
|
||
let g = new_group(d, name, color);
|
||
d.measurement.count_groups.push(g);
|
||
d.measurement.active_count_group = d.measurement.count_groups.len() - 1;
|
||
Ok(())
|
||
})?;
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
fn count_delete_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let d = doc(s)?;
|
||
let g = group_index(&d, p).ok_or_else(|| bad("count.deleteGroup", "no such count group"))?;
|
||
s.edit("Delete Count Group", |d, _| {
|
||
d.measurement.count_groups.remove(g);
|
||
let n = d.measurement.count_groups.len();
|
||
d.measurement.active_count_group = d.measurement.active_count_group.min(n.saturating_sub(1));
|
||
Ok(())
|
||
})?;
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
/// A size parameter (`markerSize`, `labelSize`) clamped to `lo..=hi`: `None` when absent or null.
|
||
/// Whole numbers sent as JSON floats (`8.0`, as UIs and recorded actions do) count; fractions,
|
||
/// negatives and non-numbers are bad params rather than silently ignored. Clamping happens in
|
||
/// `u64`/`f64`, so a huge value can't wrap through `as u32`.
|
||
fn whole_size(cmd: &str, p: &Value, key: &str, lo: u32, hi: u32) -> Result<Option<u32>> {
|
||
let v = match p.get(key) {
|
||
None | Some(Value::Null) => return Ok(None),
|
||
Some(v) => v,
|
||
};
|
||
let n = match (v.as_u64(), v.as_f64()) {
|
||
(Some(n), _) => n.clamp(u64::from(lo), u64::from(hi)),
|
||
(None, Some(f)) if f.is_finite() && f >= 0.0 && f.fract() == 0.0 => f.clamp(f64::from(lo), f64::from(hi)) as u64,
|
||
_ => return Err(bad(cmd, format!("`{key}` = {v} must be a whole number ({lo}..={hi})"))),
|
||
};
|
||
Ok(Some(u32::try_from(n).unwrap_or(hi)))
|
||
}
|
||
|
||
fn count_set_group(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "count.setGroup";
|
||
let d = doc(s)?;
|
||
let g = group_index(&d, p).ok_or_else(|| bad(CMD, "no such count group"))?;
|
||
let mut grp = d.measurement.count_groups[g].clone();
|
||
if let Some(n) = p.get("name").and_then(Value::as_str) {
|
||
grp.name = n.to_string();
|
||
}
|
||
if let Some(c) = color_of(p, "color") {
|
||
grp.color = c;
|
||
}
|
||
if let Some(v) = whole_size(CMD, p, "markerSize", 1, 10)? {
|
||
grp.marker_size = v;
|
||
}
|
||
if let Some(v) = whole_size(CMD, p, "labelSize", 8, 72)? {
|
||
grp.label_size = v;
|
||
}
|
||
if let Some(v) = p.get("visible").and_then(Value::as_bool) {
|
||
grp.visible = v;
|
||
}
|
||
let activate = p.get("active").and_then(Value::as_bool) == Some(true) || p.as_object().is_some_and(|o| o.len() == 1 && o.contains_key("group"));
|
||
let changed = grp != d.measurement.count_groups[g];
|
||
if changed || (activate && d.measurement.active_count_group != g) {
|
||
s.edit("Count Group Options", |d, _| {
|
||
d.measurement.count_groups[g] = grp;
|
||
if activate {
|
||
d.measurement.active_count_group = g;
|
||
}
|
||
Ok(())
|
||
})?;
|
||
}
|
||
Ok(count_json(doc(s)?.as_ref()))
|
||
}
|
||
|
||
// ------------------------------------------------------------------ record + log
|
||
|
||
fn base_row(s: &mut Session, d: &Document, source: &str, label: &str, now: &str) -> Map<String, Value> {
|
||
let sc = &d.measurement.scale;
|
||
let mut m = Map::new();
|
||
m.insert("label".into(), json!(label));
|
||
m.insert("dateTime".into(), json!(now));
|
||
m.insert("document".into(), json!(d.name));
|
||
m.insert(
|
||
"source".into(),
|
||
json!(match source {
|
||
"selection" => "Selection",
|
||
"ruler" => "Ruler Tool",
|
||
_ => "Count Tool",
|
||
}),
|
||
);
|
||
m.insert("scale".into(), json!(sc.describe()));
|
||
m.insert("scaleUnits".into(), json!(sc.units));
|
||
m.insert("scaleFactor".into(), json!(sc.factor()));
|
||
let _ = s;
|
||
m
|
||
}
|
||
|
||
fn feature_values(m: &mut Map<String, Value>, f: &Feature, count: usize, factor: f64) {
|
||
m.insert("count".into(), json!(count));
|
||
m.insert("area".into(), json!(round4(f.area * factor * factor)));
|
||
m.insert("perimeter".into(), json!(round4(f.perimeter * factor)));
|
||
m.insert("circularity".into(), json!(round4(f.circularity())));
|
||
m.insert("height".into(), json!(round4(f64::from(f.bounds.height()) * factor)));
|
||
m.insert("width".into(), json!(round4(f64::from(f.bounds.width()) * factor)));
|
||
m.insert("grayMin".into(), json!(round4(f.gray_min)));
|
||
m.insert("grayMax".into(), json!(round4(f.gray_max)));
|
||
m.insert("grayMean".into(), json!(round4(f.gray_mean)));
|
||
m.insert("grayMedian".into(), json!(round4(f.gray_median)));
|
||
m.insert("integratedDensity".into(), json!(round4(f.area * f.gray_mean)));
|
||
m.insert("histogram".into(), json!(f.histogram));
|
||
}
|
||
|
||
fn record(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "image.analysis.recordMeasurements";
|
||
let d = doc(s)?;
|
||
let source = match p.get("source").and_then(Value::as_str).unwrap_or("auto") {
|
||
"auto" => {
|
||
if d.selection.is_some() {
|
||
"selection"
|
||
} else if d.measurement.ruler.is_some() {
|
||
"ruler"
|
||
} else if d.measurement.count_total() > 0 {
|
||
"count"
|
||
} else {
|
||
"selection"
|
||
}
|
||
}
|
||
x @ ("selection" | "ruler" | "count") => x,
|
||
other => return Err(bad(CMD, format!("unknown source `{other}` (auto|selection|ruler|count)"))),
|
||
};
|
||
// Numbered only once the rows exist, so a failed measurement does not use up a label.
|
||
let label = format!("Measurement {}", s.analysis.next_measurement + 1);
|
||
let now = p.get("dateTime").and_then(Value::as_str).map(str::to_string).unwrap_or_else(now_iso);
|
||
let factor = d.measurement.scale.factor();
|
||
let mut rows: Vec<Map<String, Value>> = Vec::new();
|
||
match source {
|
||
"selection" => {
|
||
let (sum, feats) = selection_rows(&d)?;
|
||
let mut m = base_row(s, &d, source, &label, &now);
|
||
feature_values(&mut m, &sum, feats.len(), factor);
|
||
rows.push(m);
|
||
if feats.len() > 1 {
|
||
for (i, f) in feats.iter().enumerate() {
|
||
let mut m = base_row(s, &d, source, &format!("Feature {}", i + 1), &now);
|
||
feature_values(&mut m, f, 1, factor);
|
||
rows.push(m);
|
||
}
|
||
}
|
||
}
|
||
"ruler" => {
|
||
let r = d.measurement.ruler.ok_or_else(|| EngineError::Other("no ruler line to measure".into()))?;
|
||
let info = ruler_info(&r, &d.measurement.scale);
|
||
let mut m = base_row(s, &d, source, &label, &now);
|
||
m.insert("length".into(), info["length"].clone());
|
||
m.insert("angle".into(), info["angle"].clone());
|
||
rows.push(m);
|
||
}
|
||
_ => {
|
||
let mut m = base_row(s, &d, source, &label, &now);
|
||
m.insert("count".into(), json!(d.measurement.count_total()));
|
||
rows.push(m);
|
||
}
|
||
}
|
||
s.analysis.next_measurement += 1;
|
||
let keep = s.analysis.data_points.of(source).to_vec();
|
||
let mut ids = Vec::new();
|
||
for mut m in rows {
|
||
m.retain(|k, _| keep.iter().any(|x| x == k));
|
||
s.analysis.next_row += 1;
|
||
ids.push(s.analysis.next_row);
|
||
s.analysis.log.push(LogRow { id: s.analysis.next_row, values: m });
|
||
}
|
||
let new: Vec<&LogRow> = s.analysis.log.iter().filter(|r| ids.contains(&r.id)).collect();
|
||
Ok(json!({"source": source, "rows": new}))
|
||
}
|
||
|
||
fn log_list(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
Ok(json!({"rows": s.analysis.log, "columns": log_columns(&s.analysis.log)}))
|
||
}
|
||
|
||
/// Columns any row has, in Photoshop's order.
|
||
pub fn log_columns(rows: &[LogRow]) -> Vec<&'static str> {
|
||
COLUMNS.iter().map(|c| c.0).filter(|k| rows.iter().any(|r| r.values.contains_key(*k))).collect()
|
||
}
|
||
|
||
fn ids_param(p: &Value) -> Option<Vec<u64>> {
|
||
p.get("rows").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).collect())
|
||
}
|
||
|
||
fn log_delete(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let before = s.analysis.log.len();
|
||
match ids_param(p) {
|
||
Some(ids) => s.analysis.log.retain(|r| !ids.contains(&r.id)),
|
||
None if p.get("all").and_then(Value::as_bool) == Some(true) => s.analysis.log.clear(),
|
||
None => return Err(bad("measurementLog.delete", "pass `rows`: [ids] or `all`: true")),
|
||
}
|
||
Ok(json!({"deleted": before - s.analysis.log.len(), "remaining": s.analysis.log.len()}))
|
||
}
|
||
|
||
fn csv_field(v: &Value) -> String {
|
||
let s = match v {
|
||
Value::String(s) => s.clone(),
|
||
Value::Array(a) => a.iter().map(|x| x.to_string()).collect::<Vec<_>>().join(" "),
|
||
Value::Null => String::new(),
|
||
other => other.to_string(),
|
||
};
|
||
if s.contains([',', '"', '\n', '\r']) { format!("\"{}\"", s.replace('"', "\"\"")) } else { s }
|
||
}
|
||
|
||
/// The log (or the given rows) as CSV with Photoshop's column headers.
|
||
pub fn log_csv(rows: &[LogRow]) -> String {
|
||
let cols = log_columns(rows);
|
||
let mut out = String::new();
|
||
out.push_str(&cols.iter().map(|k| COLUMNS.iter().find(|c| c.0 == *k).map_or(*k, |c| c.1)).collect::<Vec<_>>().join(","));
|
||
out.push('\n');
|
||
for r in rows {
|
||
out.push_str(&cols.iter().map(|k| r.values.get(*k).map(csv_field).unwrap_or_default()).collect::<Vec<_>>().join(","));
|
||
out.push('\n');
|
||
}
|
||
out
|
||
}
|
||
|
||
fn log_export(s: &mut Session, p: &Value) -> Result<Value> {
|
||
let rows: Vec<LogRow> = match ids_param(p) {
|
||
Some(ids) => s.analysis.log.iter().filter(|r| ids.contains(&r.id)).cloned().collect(),
|
||
None => s.analysis.log.clone(),
|
||
};
|
||
let csv = log_csv(&rows);
|
||
match p.get("path").and_then(Value::as_str) {
|
||
Some(path) => {
|
||
crate::file_cmds::write_file(path, csv.as_bytes())?;
|
||
Ok(json!({"path": path, "rows": rows.len()}))
|
||
}
|
||
None => Ok(json!({"csv": csv, "rows": rows.len()})),
|
||
}
|
||
}
|
||
|
||
// ------------------------------------------------------------------ scale marker
|
||
|
||
/// A "nice" bar length (1, 2 or 5 × 10ⁿ) near a fifth of the image width, in logical units.
|
||
fn nice_length(d: &Document) -> f64 {
|
||
let target = f64::from(d.size.width) / 5.0 * d.measurement.scale.factor();
|
||
let e = 10f64.powf(target.log10().floor());
|
||
[5.0, 2.0, 1.0].into_iter().map(|m| m * e).find(|v| *v <= target).unwrap_or(e)
|
||
}
|
||
|
||
/// The shortest image a scale bar fits: four times its 2 px minimum height (#932).
|
||
const MIN_SCALE_MARKER_HEIGHT: f64 = 8.0;
|
||
|
||
fn place_scale_marker(s: &mut Session, p: &Value) -> Result<Value> {
|
||
const CMD: &str = "image.analysis.placeScaleMarker";
|
||
let d = doc(s)?;
|
||
let sc = d.measurement.scale.clone();
|
||
let length = p.get("length").and_then(Value::as_f64).unwrap_or_else(|| nice_length(&d));
|
||
if !(length > 0.0 && length.is_finite()) {
|
||
return Err(bad(CMD, "length must be positive"));
|
||
}
|
||
let px = length / sc.factor();
|
||
let (w, h) = (f64::from(d.size.width), f64::from(d.size.height));
|
||
if px < 1.0 || px > w {
|
||
return Err(bad(CMD, format!("a {length} {} bar is {px:.1} px; it must fit the {w} px wide image", sc.units)));
|
||
}
|
||
// The bar is at least 2 px tall and at most a quarter of the image, so a shorter image has no
|
||
// room for it (and would invert the height clamp below, which panics) (#932).
|
||
if h < MIN_SCALE_MARKER_HEIGHT {
|
||
return Err(bad(CMD, format!("the image is {h} px tall; a scale marker needs at least {MIN_SCALE_MARKER_HEIGHT} px")));
|
||
}
|
||
let font_size = p.get("fontSize").and_then(Value::as_f64).unwrap_or(12.0).clamp(1.0, 1000.0);
|
||
let font = p.get("font").and_then(Value::as_str).map(str::to_string);
|
||
let show_text = p.get("displayText").and_then(Value::as_bool).unwrap_or(true);
|
||
let text_top = match p.get("textPosition").and_then(Value::as_str).unwrap_or("bottom") {
|
||
"top" => true,
|
||
"bottom" => false,
|
||
o => return Err(bad(CMD, format!("textPosition `{o}` (top|bottom)"))),
|
||
};
|
||
let rgb: [f32; 3] = match p.get("color").and_then(Value::as_str).unwrap_or("black") {
|
||
"black" => [0.0, 0.0, 0.0],
|
||
"white" => [1.0, 1.0, 1.0],
|
||
o => return Err(bad(CMD, format!("color `{o}` (black|white)"))),
|
||
};
|
||
// Bottom-left with a margin; the text sits under (or over) the bar.
|
||
let margin = (w.min(h) * 0.03).round().max(2.0);
|
||
let bar_h = (font_size / 4.0).round().clamp(2.0, h / 4.0);
|
||
let text_h = if show_text { font_size * 1.25 } else { 0.0 };
|
||
// Keep the bar on the canvas when the text under it doesn't fit; the label is clipped instead (#932).
|
||
let bar_y = if text_top { h - margin - bar_h } else { h - margin - bar_h - text_h }.max(0.0);
|
||
// Likewise pull a bar nearly as wide as the image in from the right edge (#932).
|
||
let bar_x = margin.min(w - px);
|
||
let bar = Rect::new(bar_x.round() as i32, bar_y.round() as i32, (bar_x + px).round() as i32, (bar_y + bar_h).round() as i32);
|
||
let label = format!("{} {}", trim_num(length), sc.units);
|
||
compound(s, "Place Scale Marker", |s| {
|
||
let fmt = doc(s)?.pixel_format();
|
||
let bar_id = s.edit("Place Scale Marker", |doc, active| {
|
||
let mut l = Layer::raster("Scale Bar", fmt);
|
||
if let Some(surf) = l.surface_mut() {
|
||
surf.fill_rect(bar, &photocraft_raster::from_rgba(&fmt, [rgb[0], rgb[1], rgb[2], 1.0]));
|
||
}
|
||
let id = l.id;
|
||
doc.layers.push(Layer::group("Measurement Scale Marker", vec![l]));
|
||
*active = Some(id);
|
||
Ok(id)
|
||
})?;
|
||
let mut text_layer = Value::Null;
|
||
if show_text {
|
||
let baseline = if text_top { f64::from(bar.y0) - font_size * 0.3 } else { f64::from(bar.y1) + font_size };
|
||
let mut tp = json!({"x": bar.x0, "y": baseline, "text": label, "size": font_size, "color": [rgb[0], rgb[1], rgb[2], 1.0], "name": label});
|
||
if let Some(f) = &font {
|
||
tp["font"] = json!(f);
|
||
}
|
||
text_layer = s.execute("type.create", tp)?["layer"].clone();
|
||
}
|
||
let group = doc(s)?.layers.last().map(|l| l.id.0);
|
||
Ok(json!({"group": group, "bar": bar_id.0, "text": text_layer, "barRect": [bar.x0, bar.y0, bar.x1, bar.y1], "pixels": px, "label": label}))
|
||
})
|
||
}
|
||
|
||
fn trim_num(v: f64) -> String {
|
||
if v.fract() == 0.0 { format!("{}", v as i64) } else { format!("{v}") }
|
||
}
|
||
|
||
// ------------------------------------------------------------------ info
|
||
|
||
fn analysis_info(s: &mut Session, _p: &Value) -> Result<Value> {
|
||
let d = doc(s)?;
|
||
let m = &d.measurement;
|
||
Ok(json!({
|
||
"scale": scale_json(&m.scale),
|
||
"ruler": m.ruler.map(|r| json!({"start": r.start, "end": r.end, "protractor": r.protractor, "info": ruler_info(&r, &m.scale)})),
|
||
"count": count_json(&d),
|
||
"notes": d.notes.len(),
|
||
"logRows": s.analysis.log.len(),
|
||
}))
|
||
}
|
||
|
||
macro_rules! spec {
|
||
($id:literal, $label:literal, [$($m:literal),*], $params:literal, $en:expr, $run:expr, $journal:expr) => {
|
||
CommandSpec { id: $id, label: $label, menu: &[$($m),*], shortcut: None, params: $params, enabled: $en, run: $run, journal: $journal }
|
||
};
|
||
}
|
||
|
||
pub fn specs() -> Vec<CommandSpec> {
|
||
vec![
|
||
spec!(
|
||
"image.analysis.setMeasurementScale",
|
||
"Set Measurement Scale…",
|
||
["Image", "Analysis"],
|
||
r##"{"preset":"default|custom"="custom","pixelLength":px,"logicalLength":number,"units":str (e.g. "mm")} (no params: read the scale)"##,
|
||
has_doc,
|
||
set_scale,
|
||
true
|
||
),
|
||
spec!(
|
||
"image.analysis.selectDataPoints",
|
||
"Select Data Points…",
|
||
["Image", "Analysis"],
|
||
r##"{"selection":[keys]|{key:bool}?,"ruler":[keys]|{key:bool}?,"count":[keys]|{key:bool}?,"reset":bool=false} (keys: label,dateTime,document,source,scale,scaleUnits,scaleFactor,count,area,perimeter,circularity,height,width,grayMin,grayMax,grayMean,grayMedian,integratedDensity,histogram,length,angle)"##,
|
||
always,
|
||
select_data_points,
|
||
true
|
||
),
|
||
spec!(
|
||
"image.analysis.recordMeasurements",
|
||
"Record Measurements",
|
||
["Image", "Analysis"],
|
||
r##"{"source":"auto|selection|ruler|count"="auto"} → appended Measurement Log rows (selection: summary + one row per feature)"##,
|
||
has_doc,
|
||
record,
|
||
true
|
||
),
|
||
spec!(
|
||
"image.analysis.rulerTool",
|
||
"Ruler Tool",
|
||
["Image", "Analysis"],
|
||
r##"{"start":[x,y],"end":[x,y],"protractor":[x,y]|null?,"clear":bool=false} (no params: read) → X/Y/W/H/angle/L1/L2"##,
|
||
has_doc,
|
||
ruler_tool,
|
||
true
|
||
),
|
||
spec!(
|
||
"image.analysis.countTool",
|
||
"Count Tool",
|
||
["Image", "Analysis"],
|
||
r##"{} → count groups and markers (edit with count.*)"##,
|
||
has_doc,
|
||
count_tool,
|
||
false
|
||
),
|
||
spec!(
|
||
"image.analysis.placeScaleMarker",
|
||
"Place Scale Marker…",
|
||
["Image", "Analysis"],
|
||
r##"{"length":logical units=nice ≈ width/5,"font":str?,"fontSize":pt=12,"displayText":bool=true,"textPosition":"top|bottom"="bottom","color":"black|white"="black"}"##,
|
||
has_doc,
|
||
place_scale_marker,
|
||
true
|
||
),
|
||
spec!(
|
||
"image.analysis.straightenLayer",
|
||
"Straighten Layer",
|
||
[],
|
||
r##"{"crop":bool=(active layer is the Background)} (rotates so the ruler line is level; crop = rotate the canvas and crop to the image)"##,
|
||
has_ruler,
|
||
straighten,
|
||
true
|
||
),
|
||
spec!("image.analysis.info", "Analysis Info", [], r##"{} → scale, ruler readout, count groups, note and log counts"##, has_doc, analysis_info, false),
|
||
spec!("count.add", "Add Count", [], r##"{"x":px,"y":px,"group":index?}"##, has_doc, count_add, true),
|
||
spec!("count.remove", "Remove Count", [], r##"{"index":n,"group":index?} | {"x":px,"y":px,"radius":px=6}"##, has_doc, count_remove, true),
|
||
spec!("count.move", "Move Count", [], r##"{"index":n,"group":index?,"to":[x,y]} | {"x":px,"y":px,"to":[x,y]}"##, has_doc, count_move, true),
|
||
spec!("count.clear", "Clear Count", [], r##"{"group":index|"all"=active}"##, has_doc, count_clear, true),
|
||
spec!("count.newGroup", "New Count Group", [], r##"{"name":str?,"color":"#rrggbb"|[r,g,b]?}"##, has_doc, count_new_group, true),
|
||
spec!("count.deleteGroup", "Delete Count Group", [], r##"{"group":index=active}"##, has_doc, count_delete_group, true),
|
||
spec!(
|
||
"count.setGroup",
|
||
"Count Group Options",
|
||
[],
|
||
r##"{"group":index=active,"name":str?,"color":"#rrggbb"|[r,g,b]?,"markerSize":1..10?,"labelSize":8..72?,"visible":bool?,"active":bool?}"##,
|
||
has_doc,
|
||
count_set_group,
|
||
true
|
||
),
|
||
spec!("measurementLog.list", "Measurement Log", [], r##"{} → rows and columns"##, always, log_list, false),
|
||
spec!("measurementLog.delete", "Delete Measurements", [], r##"{"rows":[ids]}|{"all":true}"##, has_log, log_delete, true),
|
||
spec!(
|
||
"measurementLog.export",
|
||
"Export Measurements…",
|
||
[],
|
||
r##"{"path":str? (CSV file; omitted → returns the CSV text),"rows":[ids]?}"##,
|
||
always,
|
||
log_export,
|
||
true
|
||
),
|
||
]
|
||
}
|
||
|
||
#[cfg(test)]
|
||
mod tests;
|