init
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
# Photopea / Photoshop ExtendScript Dataset Generator
|
||||
|
||||
This folder contains JSX scripts to generate systematic layer-effect ground-truth images in Photopea or Photoshop.
|
||||
|
||||
## Files
|
||||
|
||||
- `generate_inner_shadow_dataset.jsx` — Short starter script for Inner Shadow.
|
||||
|
||||
## Usage
|
||||
|
||||
1. Open Photopea (https://www.photopea.com) in a browser.
|
||||
2. Press `Alt+Ctrl+I` (or use `File > Automate > Script`) to open the script runner.
|
||||
3. Paste the contents of the desired `.jsx` file and run it.
|
||||
4. Select an output folder when prompted.
|
||||
|
||||
Photopea will create a new document for each parameter combination, draw a black rectangle, apply the Inner Shadow effect, hide the background, and export a transparent PNG.
|
||||
|
||||
## Current parameter grid (short version)
|
||||
|
||||
| Parameter | Values |
|
||||
|---|---|
|
||||
| angle | 0, 90, 180, 270 |
|
||||
| distance | 5, 15, 30 |
|
||||
| size | 10, 30 |
|
||||
| choke | 0, 20 |
|
||||
| fx opacity | 75, 100 |
|
||||
| fx blend mode | Normal, Multiply |
|
||||
|
||||
Total images: `4 × 3 × 2 × 2 × 2 × 2 = 192`.
|
||||
|
||||
## Notes
|
||||
|
||||
- Blend mode keys use Photoshop's 4-character codes (`Nrml`, `Mltp`).
|
||||
- The output PNGs have transparent backgrounds, so MAE comparisons can ignore white reference pixels if needed.
|
||||
- ExtendScript error handling is basic; if a parameter combination fails, the script logs it and continues.
|
||||
|
||||
## Next steps
|
||||
|
||||
- Add more layer blend modes, fill opacity, and layer opacity variations.
|
||||
- Extend to Drop Shadow, Outer Glow, Inner Glow, Stroke, Bevel & Emboss.
|
||||
- Add a companion Rust generator that creates matching PSD files so the same dataset can be imported back into HCIE for MAE tuning.
|
||||
@@ -0,0 +1,292 @@
|
||||
/**
|
||||
* generate_inner_shadow_dataset.jsx
|
||||
*
|
||||
* Purpose:
|
||||
* Generate a systematic Inner Shadow effect dataset in Photopea.
|
||||
* Uses stringID-based ActionDescriptor for maximum Photopea compatibility.
|
||||
*
|
||||
* Logic & Workflow:
|
||||
* 1. Creates a new RGB document with transparent background.
|
||||
* 2. Adds a normal raster layer and fills the center with black.
|
||||
* 3. Applies one Inner Shadow effect via ActionDescriptor (stringID).
|
||||
* 4. Hides the background layer.
|
||||
* 5. Saves the result as a transparent PNG.
|
||||
*
|
||||
* Usage:
|
||||
* 1. Open https://www.photopea.com in a browser.
|
||||
* 2. Alt+Ctrl+I (or File > Automate > Script).
|
||||
* 3. Paste this script and run.
|
||||
* 4. Select output folder when prompted.
|
||||
*/
|
||||
|
||||
var outFolder = Folder.selectDialog("Select output folder for Inner Shadow dataset");
|
||||
if (!outFolder) {
|
||||
alert("No output folder selected. Exiting.");
|
||||
throw new Error("No output folder");
|
||||
}
|
||||
|
||||
var angles = [0, 90, 180, 270];
|
||||
var distances = [5, 15, 30];
|
||||
var sizes = [10, 30];
|
||||
var chokes = [0, 20];
|
||||
var fxOpacities = [75, 100];
|
||||
var fxBlendModes = [
|
||||
{ id: "normal", name: "normal" },
|
||||
{ id: "multiply", name: "multiply" }
|
||||
];
|
||||
|
||||
function createDocument(name, width, height) {
|
||||
var doc = app.documents.add(width, height, 72, name, NewDocumentMode.RGB, DocumentFill.TRANSPARENT);
|
||||
try {
|
||||
var bg = doc.artLayers[doc.artLayers.length - 1];
|
||||
if (bg.isBackgroundLayer) {
|
||||
bg.visible = false;
|
||||
}
|
||||
} catch (e) {}
|
||||
return doc;
|
||||
}
|
||||
|
||||
function addBlackRectangle(doc) {
|
||||
var w = doc.width;
|
||||
var h = doc.height;
|
||||
if (typeof w === "object" && typeof w.value === "function") { w = w.value; }
|
||||
if (typeof h === "object" && typeof h.value === "function") { h = h.value; }
|
||||
w = Number(w);
|
||||
h = Number(h);
|
||||
|
||||
var margin = Math.round(Math.min(w, h) * 0.25);
|
||||
|
||||
var layer = doc.artLayers.add();
|
||||
layer.name = "shape";
|
||||
|
||||
var region = [
|
||||
[margin, margin],
|
||||
[w - margin, margin],
|
||||
[w - margin, h - margin],
|
||||
[margin, h - margin]
|
||||
];
|
||||
|
||||
doc.selection.select(region);
|
||||
|
||||
var black = new SolidColor();
|
||||
black.rgb.red = 0;
|
||||
black.rgb.green = 0;
|
||||
black.rgb.blue = 0;
|
||||
|
||||
doc.selection.fill(black);
|
||||
doc.selection.deselect();
|
||||
|
||||
return layer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try applying Inner Shadow via ActionDescriptor with stringID keys.
|
||||
* Returns true on success, false on failure.
|
||||
*/
|
||||
function applyInnerShadow(params) {
|
||||
try {
|
||||
var desc = new ActionDescriptor();
|
||||
|
||||
var ref = new ActionReference();
|
||||
ref.putProperty(stringIDToTypeID("property"), stringIDToTypeID("layerEffects"));
|
||||
ref.putEnumerated(stringIDToTypeID("layer"), stringIDToTypeID("ordinal"), stringIDToTypeID("targetEnum"));
|
||||
desc.putReference(stringIDToTypeID("null"), ref);
|
||||
|
||||
var fxDesc = new ActionDescriptor();
|
||||
|
||||
var isDesc = new ActionDescriptor();
|
||||
isDesc.putBoolean(stringIDToTypeID("enabled"), true);
|
||||
|
||||
// Blend mode
|
||||
isDesc.putEnumerated(stringIDToTypeID("mode"), stringIDToTypeID("blendMode"), stringIDToTypeID(params.blendMode));
|
||||
|
||||
// Color
|
||||
var colorDesc = new ActionDescriptor();
|
||||
colorDesc.putDouble(stringIDToTypeID("red"), 0);
|
||||
colorDesc.putDouble(stringIDToTypeID("grain"), 0);
|
||||
colorDesc.putDouble(stringIDToTypeID("blue"), 0);
|
||||
isDesc.putObject(stringIDToTypeID("color"), stringIDToTypeID("RGBColor"), colorDesc);
|
||||
|
||||
// Opacity (0-100)
|
||||
isDesc.putInteger(stringIDToTypeID("opacity"), params.opacity);
|
||||
|
||||
// Angle
|
||||
isDesc.putInteger(stringIDToTypeID("localLightingAngle"), params.angle);
|
||||
|
||||
// Global light
|
||||
isDesc.putBoolean(stringIDToTypeID("useGlobalAngle"), false);
|
||||
|
||||
// Distance
|
||||
isDesc.putInteger(stringIDToTypeID("distance"), params.distance);
|
||||
|
||||
// Choke
|
||||
isDesc.putInteger(stringIDToTypeID("chokeMatte"), params.choke);
|
||||
|
||||
// Blur (size)
|
||||
isDesc.putInteger(stringIDToTypeID("blur"), params.size);
|
||||
|
||||
// Noise
|
||||
isDesc.putInteger(stringIDToTypeID("noise"), 0);
|
||||
|
||||
// Anti-alias
|
||||
isDesc.putBoolean(stringIDToTypeID("antiAlias"), true);
|
||||
|
||||
// Transfer function (contour)
|
||||
isDesc.putBoolean(stringIDToTypeID("transferSpecIsScaled"), false);
|
||||
|
||||
fxDesc.putObject(stringIDToTypeID("innerShadow"), stringIDToTypeID("innerShadow"), isDesc);
|
||||
|
||||
desc.putObject(stringIDToTypeID("to"), stringIDToTypeID("layerEffects"), fxDesc);
|
||||
|
||||
executeAction(stringIDToTypeID("set"), desc, DialogModes.NO);
|
||||
return true;
|
||||
} catch (e) {
|
||||
$.writeln("stringID failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Fallback: apply via charID-based descriptor.
|
||||
*/
|
||||
function applyInnerShadowCharID(params) {
|
||||
try {
|
||||
var desc = new ActionDescriptor();
|
||||
|
||||
var ref = new ActionReference();
|
||||
ref.putProperty(charIDToTypeID("Prpr"), charIDToTypeID("Lefx"));
|
||||
ref.putEnumerated(charIDToTypeID("Lyr "), charIDToTypeID("Ordn"), charIDToTypeID("Trgt"));
|
||||
desc.putReference(charIDToTypeID("null"), ref);
|
||||
|
||||
var fxDesc = new ActionDescriptor();
|
||||
|
||||
var isDesc = new ActionDescriptor();
|
||||
isDesc.putBoolean(charIDToTypeID("enab"), true);
|
||||
isDesc.putBoolean(charIDToTypeID("present"), true);
|
||||
isDesc.putBoolean(charIDToTypeID("showInDialog"), true);
|
||||
|
||||
// Blend mode
|
||||
isDesc.putEnumerated(charIDToTypeID("Blnd"), charIDToTypeID("BlnM"), charIDToTypeID(params.blendModeCharID));
|
||||
|
||||
// Color
|
||||
var clr = new ActionDescriptor();
|
||||
clr.putDouble(charIDToTypeID("Rd "), 0);
|
||||
clr.putDouble(charIDToTypeID("Grn "), 0);
|
||||
clr.putDouble(charIDToTypeID("Bl "), 0);
|
||||
isDesc.putObject(charIDToTypeID("Clr "), charIDToTypeID("RGBC"), clr);
|
||||
|
||||
// Opacity: 0-100 integer
|
||||
isDesc.putInteger(charIDToTypeID("Opct"), params.opacity);
|
||||
|
||||
// Angle: use lagl (local angle) instead of Angl
|
||||
isDesc.putInteger(charIDToTypeID("lagl"), params.angle);
|
||||
isDesc.putBoolean(charIDToTypeID("uglg"), false);
|
||||
|
||||
// Distance
|
||||
isDesc.putInteger(charIDToTypeID("Dstn"), params.distance);
|
||||
|
||||
// Choke
|
||||
isDesc.putInteger(charIDToTypeID("Ckmt"), params.choke);
|
||||
|
||||
// Blur
|
||||
isDesc.putInteger(charIDToTypeID("blur"), params.size);
|
||||
|
||||
// Noise
|
||||
isDesc.putInteger(charIDToTypeID("Nose"), 0);
|
||||
|
||||
fxDesc.putObject(charIDToTypeID("IrSh"), charIDToTypeID("IrSh"), isDesc);
|
||||
|
||||
desc.putObject(charIDToTypeID("T "), charIDToTypeID("Lefx"), fxDesc);
|
||||
|
||||
executeAction(charIDToTypeID("setd"), desc, DialogModes.NO);
|
||||
return true;
|
||||
} catch (e) {
|
||||
$.writeln("charID failed: " + e);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
function saveTransparentPng(doc, file) {
|
||||
var pngOptions = new PNGSaveOptions();
|
||||
pngOptions.compression = 6;
|
||||
pngOptions.interlaced = false;
|
||||
doc.saveAs(file, pngOptions, true, Extension.LOWERCASE);
|
||||
}
|
||||
|
||||
var count = 0;
|
||||
var errors = 0;
|
||||
var total = angles.length * distances.length * sizes.length * chokes.length * fxOpacities.length * fxBlendModes.length;
|
||||
|
||||
var blendModeCharMap = {
|
||||
"normal": "Nrml",
|
||||
"multiply": "Mltp"
|
||||
};
|
||||
|
||||
for (var a = 0; a < angles.length; a++) {
|
||||
for (var d = 0; d < distances.length; d++) {
|
||||
for (var s = 0; s < sizes.length; s++) {
|
||||
for (var c = 0; c < chokes.length; c++) {
|
||||
for (var o = 0; o < fxOpacities.length; o++) {
|
||||
for (var b = 0; b < fxBlendModes.length; b++) {
|
||||
var bmName = fxBlendModes[b].name;
|
||||
var bmID = fxBlendModes[b].id;
|
||||
|
||||
var fileName = "is_" +
|
||||
"a" + angles[a] + "_" +
|
||||
"d" + distances[d] + "_" +
|
||||
"s" + sizes[s] + "_" +
|
||||
"c" + chokes[c] + "_" +
|
||||
"op" + fxOpacities[o] + "_" +
|
||||
"bm" + bmName +
|
||||
".png";
|
||||
|
||||
var file = new File(outFolder + "/" + fileName);
|
||||
if (file.exists) {
|
||||
count++;
|
||||
continue;
|
||||
}
|
||||
|
||||
var params = {
|
||||
angle: angles[a],
|
||||
distance: distances[d],
|
||||
size: sizes[s],
|
||||
choke: chokes[c],
|
||||
opacity: fxOpacities[o],
|
||||
blendMode: bmID,
|
||||
blendModeCharID: blendModeCharMap[bmID] || "Nrml"
|
||||
};
|
||||
|
||||
var doc = createDocument("inner_shadow_test", 256, 256);
|
||||
var layer = addBlackRectangle(doc);
|
||||
|
||||
var applied = applyInnerShadow(params);
|
||||
if (!applied) {
|
||||
applied = applyInnerShadowCharID(params);
|
||||
}
|
||||
|
||||
if (applied) {
|
||||
try {
|
||||
saveTransparentPng(doc, file);
|
||||
} catch (e2) {
|
||||
$.writeln("Save error: " + fileName + " — " + e2);
|
||||
errors++;
|
||||
}
|
||||
} else {
|
||||
$.writeln("FAILED to apply effect: " + fileName);
|
||||
errors++;
|
||||
}
|
||||
|
||||
doc.close(SaveOptions.DONOTSAVECHANGES);
|
||||
|
||||
count++;
|
||||
if (count % 10 === 0) {
|
||||
$.writeln("Progress: " + count + " / " + total + " (errors: " + errors + ")");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
alert("Done. Generated " + count + " images, " + errors + " errors.\n" + outFolder.fsName);
|
||||
@@ -0,0 +1,206 @@
|
||||
#!/usr/bin/env python3
|
||||
"""Test Photopea inner shadow via CDP."""
|
||||
import json, time, subprocess, sys, os
|
||||
|
||||
CHROMIUM = "/snap/bin/chromium"
|
||||
PORT = 9555
|
||||
OUT_DIR = "/mnt/extra/00_PROJECTS/hcie-rust-v4/_tmp/photopea_test"
|
||||
os.makedirs(OUT_DIR, exist_ok=True)
|
||||
|
||||
def http_get(path):
|
||||
import urllib.request
|
||||
with urllib.request.urlopen(f"http://127.0.0.1:{PORT}{path}", timeout=10) as r:
|
||||
return json.loads(r.read())
|
||||
|
||||
class CDP:
|
||||
def __init__(self, ws_url):
|
||||
import websocket
|
||||
self.ws = websocket.create_connection(ws_url, timeout=60)
|
||||
self._id = 0
|
||||
|
||||
def send(self, method, params=None):
|
||||
self._id += 1
|
||||
msg = {"id": self._id, "method": method}
|
||||
if params:
|
||||
msg["params"] = params
|
||||
self.ws.send(json.dumps(msg))
|
||||
while True:
|
||||
resp = json.loads(self.ws.recv())
|
||||
if resp.get("id") == self._id:
|
||||
return resp
|
||||
|
||||
def eval_js(self, expr, await_promise=True, timeout_ms=45000):
|
||||
r = self.send("Runtime.evaluate", {
|
||||
"expression": expr,
|
||||
"awaitPromise": await_promise,
|
||||
"returnByValue": True,
|
||||
"timeout": timeout_ms,
|
||||
})
|
||||
val = r.get("result", {}).get("result", {})
|
||||
if "value" in val:
|
||||
return val["value"]
|
||||
return val
|
||||
|
||||
def close(self):
|
||||
try: self.ws.close()
|
||||
except: pass
|
||||
|
||||
def main():
|
||||
proc = subprocess.Popen([
|
||||
CHROMIUM,
|
||||
"--headless=new",
|
||||
"--no-sandbox",
|
||||
"--disable-gpu",
|
||||
"--disable-dev-shm-usage",
|
||||
f"--remote-debugging-port={PORT}",
|
||||
"--remote-allow-origins=*",
|
||||
"--window-size=1024,768",
|
||||
"about:blank",
|
||||
], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||||
|
||||
time.sleep(4)
|
||||
|
||||
try:
|
||||
targets = http_get("/json")
|
||||
ws_url = None
|
||||
for t in targets:
|
||||
if t.get("type") == "page":
|
||||
ws_url = t["webSocketDebuggerUrl"]
|
||||
break
|
||||
if not ws_url:
|
||||
print("FATAL: no page target")
|
||||
return 1
|
||||
|
||||
cdp = CDP(ws_url)
|
||||
print(f"Connected ({ws_url[:60]}...)")
|
||||
|
||||
print("[1] Navigating to photopea.com...")
|
||||
cdp.send("Page.navigate", {"url": "https://www.photopea.com"})
|
||||
|
||||
print("[2] Waiting for page load...")
|
||||
time.sleep(90)
|
||||
|
||||
r = cdp.eval_js('document.readyState', await_promise=False)
|
||||
print(f" readyState={r}")
|
||||
|
||||
# Check what globals exist
|
||||
print("[3] Probing global scope...")
|
||||
probe = cdp.eval_js("""
|
||||
JSON.stringify({
|
||||
hasApp: typeof app !== "undefined",
|
||||
hasApp2: typeof window.app !== "undefined",
|
||||
hasPhotoshop: typeof Photoshop !== "undefined",
|
||||
hasPhotoshopApp: typeof photoshop !== "undefined",
|
||||
bodyLen: document.body ? document.body.innerHTML.length : 0,
|
||||
scripts: document.scripts.length,
|
||||
title: document.title,
|
||||
iframes: document.querySelectorAll("iframe").length,
|
||||
})
|
||||
""", await_promise=False)
|
||||
print(f" globals: {probe}")
|
||||
|
||||
# Photopea uses a special mechanism - try postMessage approach
|
||||
# Photopea might listen for messages with specific format
|
||||
print("[4] Trying Photopea postMessage API...")
|
||||
|
||||
# Photopea's API: when loaded in an iframe, parent sends scripts via postMessage.
|
||||
# But we're ON the photopea.com page. Let's try app.executeScript or direct eval.
|
||||
r2 = cdp.eval_js("""
|
||||
(function() {
|
||||
// Try to find app in various scopes
|
||||
var found = [];
|
||||
try { if (window.app) found.push("window.app"); } catch(e) {}
|
||||
try { if (self.app) found.push("self.app"); } catch(e) {}
|
||||
try { if (globalThis.app) found.push("globalThis.app"); } catch(e) {}
|
||||
|
||||
// Check if there are any Photopea-specific globals
|
||||
var keys = Object.keys(window).filter(k =>
|
||||
k.toLowerCase().includes("photo") ||
|
||||
k.toLowerCase().includes("ppea") ||
|
||||
k.toLowerCase().includes("pp") ||
|
||||
k.toLowerCase().includes("app")
|
||||
);
|
||||
|
||||
return JSON.stringify({found: found, photoKeys: keys.slice(0, 20)});
|
||||
})()
|
||||
""", await_promise=False)
|
||||
print(f" probe result: {r2}")
|
||||
|
||||
# Try running a script via Photopea's mechanism
|
||||
# Photopea can receive scripts via URL hash: photopea.com/#script_here
|
||||
# But more importantly, it exposes app when loaded as intended
|
||||
print("[5] Trying hash-based script execution...")
|
||||
script = 'var doc = app.documents.add(256, 256, 72, "test", NewDocumentMode.RGB, DocumentFill.TRANSPARENT); app.echoToOE("OK");'
|
||||
import base64
|
||||
b64 = base64.b64encode(script.encode()).decode()
|
||||
cdp.send("Page.navigate", {"url": f"https://www.photopea.com#{script}"})
|
||||
time.sleep(15)
|
||||
|
||||
r3 = cdp.eval_js('typeof app !== "undefined" ? "app_FOUND" : "no_app"', await_promise=False)
|
||||
print(f" after hash nav: {r3}")
|
||||
|
||||
# Maybe we need to add --enable-webgl or similar
|
||||
# Let's try a completely different approach: use Photopea's RPC mechanism
|
||||
print("[6] Checking if Photopea uses MessagePort or ServiceWorker...")
|
||||
r4 = cdp.eval_js("""
|
||||
(function() {
|
||||
var info = {};
|
||||
try { info.workers = navigator.serviceWorker ? "available" : "not available"; } catch(e) { info.workers = "err:" + e; }
|
||||
try { info.sockets = typeof WebSocket !== "undefined" ? "available" : "not"; } catch(e) {}
|
||||
// Check for MessageChannel
|
||||
try {
|
||||
var ch = new MessageChannel();
|
||||
info.messageChannel = "available";
|
||||
} catch(e) { info.messageChannel = "err"; }
|
||||
|
||||
// Check canvas elements (Photopea renders to canvas)
|
||||
var canvases = document.querySelectorAll("canvas");
|
||||
info.canvasCount = canvases.length;
|
||||
info.canvasDims = [];
|
||||
canvases.forEach(function(c) { info.canvasDims.push(c.width + "x" + c.height); });
|
||||
|
||||
return JSON.stringify(info);
|
||||
})()
|
||||
""", await_promise=False)
|
||||
print(f" {r4}")
|
||||
|
||||
# Last resort: check the actual Photopea API docs
|
||||
# Photopea exposes `app` on window when the UI is loaded
|
||||
# It might need WebGL canvas to be created first
|
||||
# Let's check if there's a #pea-app or similar container
|
||||
r5 = cdp.eval_js("""
|
||||
(function() {
|
||||
var divs = document.querySelectorAll("div[id]");
|
||||
var ids = [];
|
||||
divs.forEach(function(d) { ids.push(d.id); });
|
||||
return JSON.stringify(ids.slice(0, 30));
|
||||
})()
|
||||
""", await_promise=False)
|
||||
print(f" div ids: {r5}")
|
||||
|
||||
print("\n=== CONCLUSION ===")
|
||||
print("Photopea loaded but `app` is NOT in the global JS scope.")
|
||||
print("This means Photopea's scripting API is only available via postMessage")
|
||||
print("when Photopea is embedded as an iframe in another page.")
|
||||
print("")
|
||||
print("The correct approach for batch generation is:")
|
||||
print(" - The user runs the JSX script manually in Photopea's Script runner")
|
||||
print(" - OR use a different tool (GIMP Script-Fu, Python PIL)")
|
||||
|
||||
return 0
|
||||
|
||||
except Exception as e:
|
||||
print(f"EXCEPTION: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return 1
|
||||
finally:
|
||||
try: cdp.close()
|
||||
except: pass
|
||||
try: proc.kill()
|
||||
except: pass
|
||||
try: proc.wait(timeout=5)
|
||||
except: pass
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main() or 0)
|
||||
Reference in New Issue
Block a user