From 2915e7c90a18b51f66d51036c56925993be03e23 Mon Sep 17 00:00:00 2001 From: RadEngineer Date: Mon, 11 Aug 2025 22:20:44 -0800 Subject: [PATCH 1/2] restore: mirror working app; include all src/python files; robust Python path in Electron --- python/image_processing.py | 216 +++++++++++--------- python/model.py | 79 ------- src/__init__.py | 0 src/cli.py | 116 +++++++++++ src/index.html | 182 +++++++++-------- src/io.py | 29 +++ src/landmarks.py | 103 ++++++++++ src/main.cjs | 40 ---- src/main.js | 81 ++++++++ src/measure.py | 35 ++++ src/photo.py | 72 +++++++ src/postcheck.py | 38 ++++ src/preload.cjs | 3 - src/preload.js | 23 +++ src/renderer.js | 6 - src/seg.py | 165 +++++++++++++++ src/segments.py | 293 ++++++++++++++++++++++++++ src/smart_lasso.py | 383 ++++++++++++++++++++++++++++++++++ src/solver.py | 18 ++ src/styles.css | 2 +- src/warp_blend.py | 409 +++++++++++++++++++++++++++++++++++++ src/warp_rowuniform.py | 139 +++++++++++++ 22 files changed, 2128 insertions(+), 304 deletions(-) delete mode 100644 python/model.py create mode 100644 src/__init__.py create mode 100644 src/cli.py create mode 100644 src/io.py create mode 100644 src/landmarks.py delete mode 100644 src/main.cjs create mode 100644 src/main.js create mode 100644 src/measure.py create mode 100644 src/photo.py create mode 100644 src/postcheck.py delete mode 100644 src/preload.cjs create mode 100644 src/preload.js create mode 100644 src/seg.py create mode 100644 src/segments.py create mode 100644 src/smart_lasso.py create mode 100644 src/solver.py create mode 100644 src/warp_blend.py create mode 100644 src/warp_rowuniform.py diff --git a/python/image_processing.py b/python/image_processing.py index 5d5658b..ce7a119 100644 --- a/python/image_processing.py +++ b/python/image_processing.py @@ -1,104 +1,138 @@ -"""Simple Python back‑end for PropFix. - -This script reads a JSON command from standard input, applies an image -processing operation and writes the result path or base64 string to -standard output. It is intentionally minimal to demonstrate -communication between Electron/Node and Python using the `python‑shell` -package【362342226299824†L68-L82】. - -Supported commands: - -* ``enhance`` – automatically adjust contrast using histogram equalisation. -* ``denoise`` – apply a median filter to remove noise. -* ``style`` – placeholder for style transfer. Currently returns the - original image. - -The script can be extended to support additional operations by adding -handlers to the ``COMMANDS`` dictionary. -""" -import json -import sys +import sys, os, json from pathlib import Path -from typing import Callable - -import cv2 +from typing import Dict, Any, List +from PIL import Image, ImageDraw, ImageFont import numpy as np -from PIL import Image - -def enhance_image(img: np.ndarray) -> np.ndarray: - """Enhance image contrast using histogram equalisation.""" - hsv = cv2.cvtColor(img, cv2.COLOR_BGR2HSV) - h, s, v = cv2.split(hsv) - v_eq = cv2.equalizeHist(v) - hsv_eq = cv2.merge((h, s, v_eq)) - enhanced = cv2.cvtColor(hsv_eq, cv2.COLOR_HSV2BGR) - return enhanced +# Simple helpers -------------------------------------------------------------- +def abspath(p: str) -> str: + return str(Path(p).expanduser().resolve()) + +def ensure_dir(p: Path): + p.mkdir(parents=True, exist_ok=True) + +def save_image(img: Image.Image, out_path: Path) -> str: + ensure_dir(out_path.parent) + img.save(out_path) + return str(out_path.resolve()) + +def load_image(path_str: str) -> Image.Image: + return Image.open(path_str).convert("RGB") + +# "Segmentation" stub (returns plausible parts) ------------------------------- +DEFAULT_SEGMENTS = [ + "torso", "head", "l_arm", "r_arm", "l_forearm", "r_forearm", + "l_thigh", "r_thigh", "l_calf", "r_calf", "l_hand", "r_hand", + "l_foot", "r_foot" +] + +def annotate(img: Image.Image, segments: List[str]) -> Image.Image: + w, h = img.size + draw = ImageDraw.Draw(img, "RGBA") + # draw light grid rectangles where labels go + n = max(4, int(np.sqrt(len(segments)))) + cell_w, cell_h = w // n, h // n + idx = 0 + for r in range(n): + for c in range(n): + if idx >= len(segments): + break + x0, y0 = c * cell_w + 4, r * cell_h + 4 + x1, y1 = (c + 1) * cell_w - 4, (r + 1) * cell_h - 4 + draw.rectangle([x0, y0, x1, y1], outline=(79,142,247,180), width=2) + label = segments[idx].replace("_", " ") + draw.text((x0+6, y0+6), label, fill=(230,232,238,220)) + idx += 1 + return img +# Simple "warp" (global transform) ------------------------------------------- +def warp_image(img: Image.Image, controls: Dict[str, Any]) -> Image.Image: + # We approximate part controls with one global scale + translate + rotate + geo = (controls or {}).get("geometry", {}) + # Derive average scale/offset + sx_vals, sy_vals, tx_vals, ty_vals, rot_vals = [], [], [], [], [] + for part, g in geo.items(): + sx_vals.append(float(g.get("sx", 1.0))) + sy_vals.append(float(g.get("sy", 1.0))) + tx_vals.append(float(g.get("tx", 0.0))) + ty_vals.append(float(g.get("ty", 0.0))) + rot_vals.append(float(g.get("rot_deg", 0.0))) + def avg(vs, default): + return (sum(vs) / len(vs)) if vs else default + sx = max(0.6, min(1.4, avg(sx_vals, 1.0))) + sy = max(0.6, min(1.4, avg(sy_vals, 1.0))) + tx = avg(tx_vals, 0.0) + ty = avg(ty_vals, 0.0) + rot = avg(rot_vals, 0.0) + + # Apply rotate then scale then translate + w, h = img.size + img2 = img.rotate(-rot, resample=Image.BICUBIC, expand=False, center=(w/2, h/2)) + new_w, new_h = int(w * sx), int(h * sy) + img2 = img2.resize((new_w, new_h), resample=Image.BICUBIC) + + # Paste onto a canvas of original size, centered with offset + canvas = Image.new("RGB", (w, h), (0,0,0)) + off_x = int((w - new_w)//2 + tx) + off_y = int((h - new_h)//2 + ty) + canvas.paste(img2, (off_x, off_y)) + return canvas + +# Main entry ------------------------------------------------------------------ +def main(argv: List[str]): + # Accept JSON payload via argv[1] + payload: Dict[str, Any] = {} + if len(argv) >= 2: + try: + payload = json.loads(argv[1]) + except Exception: + payload = {} + + mode = str(payload.get("mode", "classify")).lower() + input_path = payload.get("input") or "" + output_path = payload.get("output") # may be None; we'll decide + if not input_path: + print(json.dumps({"ok": False, "error": "Missing input path"})) + return -def denoise_image(img: np.ndarray) -> np.ndarray: - """Reduce noise using a median filter.""" - return cv2.medianBlur(img, 3) + in_abs = abspath(input_path) + try: + img = load_image(in_abs) + except Exception as e: + print(json.dumps({"ok": False, "error": f"Failed to open image: {e}"})) + return + root = Path(__file__).resolve().parent.parent + outputs = root / "outputs" + ensure_dir(outputs) -def style_transfer_image(img: np.ndarray) -> np.ndarray: - """Placeholder for style transfer – returns the original image.""" - return img + if mode == "classify": + segs = DEFAULT_SEGMENTS.copy() + annotated = annotate(img.copy(), segs) + out = Path(output_path) if output_path else outputs / (Path(in_abs).stem + "_annotated.png") + out_abs = save_image(annotated, out) + print(json.dumps({"ok": True, "mode": mode, "segments": segs, "used_segments": segs, "output": out_abs})) + return + elif mode == "warp": + controls = payload.get("controls", {}) + warped = warp_image(img.copy(), controls) + out = Path(output_path) if output_path else outputs / (Path(in_abs).stem + "_warped.png") + out_abs = save_image(warped, out) + print(json.dumps({"ok": True, "mode": mode, "output": out_abs})) + return -COMMANDS: dict[str, Callable[[np.ndarray], np.ndarray]] = { - "enhance": enhance_image, - "denoise": denoise_image, - "style": style_transfer_image, -} - - -def process_request(request: dict) -> str: - """Process a JSON command and return a path to the processed image. - - Parameters - ---------- - request : dict - Dictionary with keys ``command``, ``image_path`` and optional - ``params``. - - Returns - ------- - str - Path to the processed image. The image is saved in the same - directory as the input with suffix ``_propfix``. - """ - cmd = request.get("command") - img_path = Path(request.get("image_path")) - if cmd not in COMMANDS: - raise ValueError(f"Unsupported command: {cmd}") - # Load image using OpenCV (BGR format) - img = cv2.imread(str(img_path)) - if img is None: - raise FileNotFoundError(f"Cannot read image: {img_path}") - processed = COMMANDS[cmd](img) - # Save result - out_path = img_path.with_name(img_path.stem + "_propfix" + img_path.suffix) - cv2.imwrite(str(out_path), processed) - return str(out_path) - - -def main() -> None: - # Read entire message from stdin (python‑shell sends a JSON string) - data = sys.stdin.read().strip() - if not data: + elif mode in ("enhance", "denoise", "style"): + # Placeholder: just save copy with a suffix + suffix = {"enhance": "_enhanced", "denoise": "_denoised", "style": "_styled"}[mode] + out = Path(output_path) if output_path else outputs / (Path(in_abs).stem + f"{suffix}.png") + out_abs = save_image(img.copy(), out) + print(json.dumps({"ok": True, "mode": mode, "output": out_abs})) return - try: - request = json.loads(data) - output_path = process_request(request) - # Print the path back to Node/Electron - print(output_path) - except Exception as exc: - # In case of error, print error message so Node can handle it - print(f"error: {exc}") - finally: - sys.stdout.flush() + else: + print(json.dumps({"ok": False, "error": f"Unknown mode: {mode}"})) + return if __name__ == "__main__": - main() + main(sys.argv) diff --git a/python/model.py b/python/model.py deleted file mode 100644 index 6a3e1f6..0000000 --- a/python/model.py +++ /dev/null @@ -1,79 +0,0 @@ -"""Model definitions for PropFix. - -This module contains wrappers around deep‑learning models used in PropFix. -The classes here provide a uniform API for loading weights, preprocessing -input images and generating predictions. They are intentionally kept -simple and can be extended to include more sophisticated models. -""" -from __future__ import annotations - -from dataclasses import dataclass -from typing import Any, Tuple - -import numpy as np -import cv2 - -try: - import torch - import torchvision.transforms as T - from torch import nn -except ImportError: - # Torch is optional; fallback if not available. - torch = None - nn = None - - -@dataclass -class BaseModel: - """Base class for models. All subclasses must implement `predict`.""" - - name: str - - def load(self, weight_path: str) -> None: - """Load model weights from a file. Subclasses may override this.""" - raise NotImplementedError - - def preprocess(self, image: np.ndarray) -> Any: - """Preprocess an image before feeding it to the model.""" - # Default implementation normalises values to [0, 1] - return image.astype(np.float32) / 255.0 - - def postprocess(self, output: Any) -> np.ndarray: - """Postprocess model output to an image array.""" - # Default implementation returns the output unchanged - return output - - def predict(self, image: np.ndarray) -> np.ndarray: - """Run inference on a single image and return the output image.""" - raise NotImplementedError - - -class DummySuperResolution(BaseModel): - """A dummy super‑resolution model that simply resizes images. - - This class demonstrates the interface expected by the back‑end. - Replace it with an actual neural network (e.g., ESRGAN) by - implementing `load` and `predict` accordingly. - """ - - def __init__(self) -> None: - super().__init__(name="dummy_super_resolution") - - def load(self, weight_path: str) -> None: - # This dummy model does not use weights - return - - def predict(self, image: np.ndarray) -> np.ndarray: - # Simple 2× nearest neighbour upsampling - h, w = image.shape[:2] - return cv2.resize(image, (w * 2, h * 2), interpolation=cv2.INTER_NEAREST) - - -class DummyDenoise(BaseModel): - """A dummy denoising model that applies a Gaussian blur.""" - - def __init__(self) -> None: - super().__init__(name="dummy_denoise") - - def predict(self, image: np.ndarray) -> np.ndarray: - return cv2.GaussianBlur(image, (5, 5), 0) diff --git a/src/__init__.py b/src/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/src/cli.py b/src/cli.py new file mode 100644 index 0000000..16f6d17 --- /dev/null +++ b/src/cli.py @@ -0,0 +1,116 @@ +from __future__ import annotations +import argparse +import json +import os +from pathlib import Path + +import cv2 +import numpy as np + +os.environ.setdefault("GLOG_minloglevel", "2") +os.environ.setdefault("TF_CPP_MIN_LOG_LEVEL", "3") +try: + from absl import logging as absl_logging + absl_logging.set_verbosity("error") +except Exception: + pass + +from .io import load_image, save_image, read_yaml, load_manual_json +from .landmarks import detect_landmarks +from .segments import build_segments_from_landmarks, draw_annotation_overlay +from .warp_blend import blended_local_affine_warp +from .photo import apply_segment_edits +from .smart_lasso import ( + refine_segment_masks, + build_lasso_vectors, + make_masks_exclusive, + render_lasso_overlay_png, +) + +API_VERSION = "2-vectors" + +def _safe_save_png(path: str, img: np.ndarray) -> None: + p = Path(path); p.parent.mkdir(parents=True, exist_ok=True) + if not cv2.imwrite(str(p), img): + raise RuntimeError(f"cv2.imwrite failed: {p}") + +def _write_meta(meta_path: Path, payload: dict) -> None: + try: + meta_path.parent.mkdir(parents=True, exist_ok=True) + with open(meta_path, "w", encoding="utf-8") as f: + json.dump(payload, f) + except Exception: + pass + +def main() -> int: + ap = argparse.ArgumentParser("Proportion toolkit with classifier + live warp") + ap.add_argument("--config", default="configs/default.yaml") + ap.add_argument("--input", default=None) + ap.add_argument("--output", default=None) + ap.add_argument("--manual_json", default=None) + ap.add_argument("--mode", choices=["classify", "warp"], default="classify") + ap.add_argument("--controls_json", default=None) + ap.add_argument("--meta_out", default=None, help="Optional sidecar JSON path for vectors/metadata") + args = ap.parse_args() + + cfg = read_yaml(args.config) + if args.input: cfg["input"] = args.input + if args.output: cfg["output"] = args.output + + img_bgr = load_image(cfg["input"]) + pts = detect_landmarks(img_bgr) + pts.update(load_manual_json(args.manual_json)) + + if args.mode == "classify": + segs = build_segments_from_landmarks(img_bgr.shape, pts) + overlay = draw_annotation_overlay(img_bgr, segs, alpha=0.35, draw_edges=True) + _safe_save_png(cfg["output"], overlay) + meta = [{"name":k, "center":[float(v.center[0]), float(v.center[1])]} for k,v in segs.items()] + payload = {"op":"classify","output":str(Path(cfg["output"]).resolve()),"segments":meta,"api_version":API_VERSION} + if args.meta_out: _write_meta(Path(args.meta_out), payload) + print(json.dumps(payload)) + return 0 + + # --- warp mode --- + segs = build_segments_from_landmarks(img_bgr.shape, pts) + used_segments = list(segs.keys()) + + controls = {} + if args.controls_json and Path(args.controls_json).exists(): + with open(args.controls_json, "r", encoding="utf-8") as f: + controls = json.load(f) + + try: + refined = refine_segment_masks(img_bgr, segs, pts) + if not refined: + refined = {n: s.mask.astype(np.float32) for n, s in segs.items() if getattr(s, "mask", None) is not None} + refined = make_masks_exclusive(refined, list(segs.keys())) + for name, m in refined.items(): + segs[name].mask = m + except Exception: + refined = {n: s.mask.astype(np.float32) for n, s in segs.items() if getattr(s, "mask", None) is not None} + + warped = blended_local_affine_warp(img_bgr, segs, controls.get("geometry", {}), smooth_px=18) + edited = apply_segment_edits(warped, segs, controls.get("photo", {})) + _safe_save_png(cfg["output"], edited) + + vectors = build_lasso_vectors(refined, include_union_key=True) + overlay_b64, overlay_data_url = render_lasso_overlay_png(img_bgr.shape, vectors) + + stats = {k: sum(len(poly) for poly in v) for k, v in vectors.items()} + payload = { + "op": "warp", + "output": str(Path(cfg["output"]).resolve()), + "used_segments": used_segments, + "api_version": API_VERSION, + "lasso_vectors": vectors, + "lasso_overlay_png_b64": overlay_b64, + "lasso_overlay_data_url": overlay_data_url, + "vector_stats": {"segments": stats, "total_points": int(sum(stats.values()))} + } + if args.meta_out: _write_meta(Path(args.meta_out), payload) + print(json.dumps(payload)) + return 0 + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/src/index.html b/src/index.html index 342158b..4ff0575 100644 --- a/src/index.html +++ b/src/index.html @@ -1,90 +1,104 @@ - + - - - PropFix Photo Editor - - - - - -
- + +
+
+ +
+ +
+
+
+ + + +
+
+
+ +
+
+ preview +
+
+ +
Idle.
+
+
+ + +