Replace IOPaint with a verified standalone LaMA runtime (#88)

Replace the IOPaint dependency with a small verified TorchScript LaMA adapter, shared dependency manifests, reliable model preparation, and cross-platform application validation.

Preserve crop/pad/composite behavior while handling empty masks and tiny inputs safely. Add integrity, offline, installer, image/video, and desktop backend coverage.

Closes #48. Closes #49.
This commit is contained in:
D-Ogi authored and GitHub committed 2026-09-16 07:07:04 +02:00
1 parent 66d0c6b30b
commit ae98ac0e10
24 files changed
+1417 -335

No files matched your search

+47 -1
View File
@@ -36,7 +36,7 @@ jobs:
- name: Check Python correctness
run: python -m ruff check .
- name: Compile application and tests without importing models
run: python -m compileall -q remwm.py remwmgui.py utils.py tests
run: python -m compileall -q remwm.py remwmgui.py utils.py lama_inpaint tests
- name: Run CPU tests
run: python -m pytest
- name: Check Bash syntax
@@ -57,3 +57,49 @@ jobs:
$parseErrors | ForEach-Object { Write-Error $_ }
exit 1
}
application-tests:
name: Application runtime (${{ matrix.os }})
strategy:
fail-fast: false
matrix:
os: [ubuntu-latest, windows-latest, macos-latest]
runs-on: ${{ matrix.os }}
timeout-minutes: 25
env:
MPLBACKEND: Agg
HF_HUB_OFFLINE: "1"
TRANSFORMERS_OFFLINE: "1"
steps:
- uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
with:
persist-credentials: false
- uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: "3.12"
- name: Install CPU PyTorch (Linux/Windows)
if: runner.os != 'macOS'
run: python -m pip install torch torchvision --index-url https://download.pytorch.org/whl/cpu
- name: Install PyTorch (macOS)
if: runner.os == 'macOS'
run: python -m pip install torch torchvision
- name: Install full application with the normal resolver
run: python -m pip install -r requirements.txt pytest matplotlib
- name: Verify full application environment
run: |
python -m pip check
python -c "import remwm, webview, yaml, psutil; import importlib.util; assert importlib.util.find_spec('iopaint') is None"
python remwm.py --help
- name: Exercise application and TorchScript adapter without model downloads
run: python -m pytest
- name: Install Linux desktop system libraries
if: runner.os == 'Linux'
run: |
sudo apt-get update
sudo apt-get install -y libegl1 libgl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-randr0 libnss3 libasound2t64
- name: Check installed desktop backend (Linux)
if: runner.os == 'Linux'
run: xvfb-run -a python scripts/check_webview.py
- name: Check installed desktop backend (Windows/macOS)
if: runner.os != 'Linux'
run: python scripts/check_webview.py
+4 -2
View File
@@ -20,7 +20,9 @@ python -m compileall -q remwm.py remwmgui.py utils.py tests
CI runs these checks on Windows, Linux, and macOS. It also parses the PowerShell installer and checks Bash syntax and line endings. The lint baseline catches syntax errors and selected correctness errors; it does not require reformatting existing application code.
These checks use CPU fixtures. They do not install the full application, launch its GUI, download model weights, or prove CUDA/MPS compatibility. The test dependencies are separate from the application dependencies. A successful `pip check` in the test environment only validates that environment.
These lightweight checks use CPU fixtures. They do not install the full application, launch its GUI, download model weights, or prove CUDA/MPS compatibility. The test dependencies are separate from the application dependencies. A successful `pip check` in the test environment only validates that environment.
A separate application CI job installs `requirements.txt` on all three platforms with the normal resolver, runs `pip check` in that environment, verifies application imports and CLI help, and tests the adapter using a generated TorchScript fixture. It also checks image and short-video routing without downloading detection models. This job does not prove a GUI interaction or real-model/GPU inference. See [LaMA runtime validation](docs/lama-runtime.md#validation).
## Evidence required by change type
@@ -45,7 +47,7 @@ GPU/model integration tests require a separate trusted environment and must not
Automated review provides suggestions; maintainers verify findings against the code and test evidence. A bot's approval does not replace application testing. Resolve applicable review findings or explain why they do not apply. Maintainers decide when to merge.
Once the new CI jobs have passed reliably, maintainers can require their status checks through repository rules. Adding this workflow alone does not enable branch protection. Full installation and GUI automation remain follow-up work while the application's dependency and backend setup issues are being resolved.
Once the new CI jobs have passed reliably, maintainers can require their status checks through repository rules. Adding this workflow alone does not enable branch protection. Full-manifest installation checks run in the application job; installer-script execution and GUI interaction still require separate validation on affected platforms.
## Dependency updates
+7
View File
@@ -162,3 +162,10 @@ This project is licensed under the MIT License. See the [LICENSE](LICENSE) file
[![Star History Chart](https://api.star-history.com/svg?repos=D-Ogi/WatermarkRemover-AI&type=date&legend=top-left)](https://www.star-history.com/#D-Ogi/WatermarkRemover-AI&type=date&legend=top-left)
## LaMA runtime and installation troubleshooting
LaMA inpainting runs directly from verified TorchScript weights; IOPaint is no
longer required. See [runtime, cache and migration instructions](docs/lama-runtime.md)
when upgrading an older installation. Use a fresh application environment and check
it with `python -m pip check` to avoid retaining old IOPaint dependency conflicts.
+28
View File
@@ -0,0 +1,28 @@
# Third-party notices
## LaMA processing adapter
The crop, symmetric padding, tensor normalization and compositing behavior in
`lama_inpaint` is adapted from [Sanster/IOPaint](https://github.com/Sanster/IOPaint),
revision `61a759fb3f332bacdce8b2813f4837495c9b86e0`, specifically
`iopaint/model/lama.py`, `iopaint/model/base.py` and `iopaint/helper.py`.
The upstream Apache-2.0 license is reproduced in
[licenses/IOPaint-Apache-2.0.txt](licenses/IOPaint-Apache-2.0.txt).
Changes: standalone RGB prediction/BGR output adapter; input validation;
non-mutating compositing; empty-mask bypass; soft-mask crop selection;
verified atomic model downloads; removal of the IOPaint registry, schemas,
CLI, diffusion models and their runtime dependencies.
## Model weights
Weights are downloaded separately from the existing upstream
[big-lama.pt release](https://github.com/Sanster/models/releases/tag/add_big_lama).
They are not included in this repository. The artifact is 205669692 bytes;
SHA-256: `344c77bbcb158f17dd143070d1e789f38a66c04202311ae3a258ef66667a9ea9`.
This digest was calculated from the artifact matching IOPaint's pinned MD5
`e3aa4aaa15225a33ec84f9f4bc47e500`; the legacy release has no GitHub SHA-256
asset digest. The runtime uses the pinned SHA-256, not MD5 or an environment
variable supplied checksum. This is an integrity pin, not an upstream signature.
For model provenance and terms, see [LaMA](https://github.com/advimman/lama)
and the artifact publisher. Code licensing does not replace model/data terms.
+134
View File
@@ -0,0 +1,134 @@
# Standalone LaMA runtime
The application loads the existing `big-lama.pt` TorchScript model directly.
IOPaint, its model registry/CLI, diffusion backends, and request schemas are no
longer needed. Florence-2 detection, desktop themes and translations retain their
existing behavior.
## Installation and migration
Use a fresh application environment when upgrading an installation that contains
IOPaint. Leaving that package installed can retain conflicting requirements even
though the application no longer imports it. The installers do not remove packages
from another environment and do not suppress dependency failures.
For a manual installation with Python 3.12:
```sh
python -m venv .venv-app
# Linux/macOS: . .venv-app/bin/activate
# Windows PowerShell: .venv-app\Scripts\Activate.ps1
python -m pip install -r requirements.txt
python -m pip check
python -m lama_inpaint download
python remwm.py --help
```
For a headless CLI installation, use `requirements-core.txt` instead. Select a
matching torch/torchvision build for your device before installing requirements;
the generic manifest does not guarantee a CUDA build. Linux desktop installation
uses pywebview's Qt extra, Windows keeps its native backend, and macOS keeps its
native backend. Linux still needs a graphical session and the Qt system libraries. On Ubuntu
24.04, prepare the desktop libraries with:
```sh
sudo apt-get install libegl1 libgl1 libxkbcommon-x11-0 libxcb-cursor0 libxcb-icccm4 libxcb-keysyms1 libxcb-shape0 libxcb-xinerama0 libxcb-randr0 libnss3 libasound2t64
```
Package names may differ on other distributions. The installer does not silently
install system packages or elevate privileges.
The standalone runtime removes the IOPaint `imghdr` import path. This does not by
itself certify every dependency or installer on Python 3.13+. The application CI
currently validates Python 3.12.
## Model integrity and offline use
All three installers and runtime loading use the same model store. It reuses
`$TORCH_HOME/hub/checkpoints/big-lama.pt`, or otherwise
`$XDG_CACHE_HOME/torch/hub/checkpoints/big-lama.pt` (default `~/.cache/torch/...`).
An existing valid IOPaint cache is reused without downloading it again. A custom
`torch.hub.set_dir()` made by unrelated Python code is not consulted.
```sh
python -m lama_inpaint download
python -m lama_inpaint download --offline
python -m lama_inpaint download --cache-dir /path/to/checkpoints
```
`--cache-dir` applies to that preparation command; set `TORCH_HOME` for the normal
application to use a nondefault cache. Offline verification fails if the file is
missing or corrupt and never opens a network connection. Programmatic callers
can use `LamaInpaint(device="cpu", download=False)` for strict offline loading.
The artifact URL, exact byte count and SHA-256 are pinned in
`lama_inpaint/model_store.py`; see [provenance](../THIRD_PARTY_NOTICES.md).
Cached weights are verified before deserialization. Downloads have read/time/size
limits, a cross-process lock, a unique temporary file and atomic replacement after
verification. Interrupted or invalid downloads never become the cached model and
do not delete an existing artifact. An invalid cache is replaced only after a new
download passes verification. No user-provided URL or checksum environment variable
can bypass the pinned artifact check.
## Restricted networks
The China option changes the package and Florence-2 mirrors. LaMA still uses the
pinned GitHub artifact; there is no unverified model mirror or URL override.
If GitHub is unavailable, preseed the cache before running setup:
1. On a machine with GitHub access and these application packages installed, run
`python -m lama_inpaint download --cache-dir model-transfer`.
2. Transfer `model-transfer/big-lama.pt` to the target machine. Place it at
`TORCH_HOME/hub/checkpoints/big-lama.pt` if you set `TORCH_HOME`, otherwise at
`~/.cache/torch/hub/checkpoints/big-lama.pt`. If `XDG_CACHE_HOME` is set, use
`XDG_CACHE_HOME/torch/hub/checkpoints/big-lama.pt` instead of `~/.cache/...`.
Keep the cache environment variables consistent when running setup and the app.
3. Run setup normally with the chosen package mirror. Every installer checks the
local artifact before attempting any model network request. Valid preseeded
weights are reused; missing or incorrect weights still cause setup to fail if
GitHub cannot be reached. Do not bypass the checksum check.
4. In the installed application environment, confirm the cache without network
access using `python -m lama_inpaint download --offline`.
Linux installs CPU and CUDA PyTorch builds from their respective official wheel
indexes, including when a different mirror is selected for other packages. The
CUDA branch currently selects CUDA 12.4; this is not a promise of support for every
GPU/driver generation. Hardware requiring another build still needs an explicitly
selected compatible torch/torchvision pair.
## Processing contract
- Input: nonempty `uint8` RGB array `(H, W, 3)` and matching `uint8` mask `(H, W)`.
- Output: independent `uint8` BGR array, preserving the application's existing API.
- Values above zero select model pixels; mask values divided by 255 determine the
final composite. Zero-mask pixels are preserved exactly.
- Images above 800 pixels on the longest side use connected-region crops with the
existing 64-pixel context margin. Crops are predicted from the original image,
including overlapping context regions.
- Symmetric padding aligns dimensions to multiples of 8. Tiny inputs are padded
to at least 32 pixels per dimension, then cropped back; they are never resized.
- Empty masks bypass inference. Caller-owned image/mask arrays are never modified.
- CPU and CUDA are supported. An unavailable requested CUDA device fails explicitly;
MPS is rejected until its operators and results are validated.
The old `resize_limit=1600` belonged to an unused RESIZE strategy; the application
selected CROP. It was not a hard cap on a full-image mask. The new adapter likewise
does not resize large masks silently. Memory requirements still depend on crop size.
Intentional differences from the previous wrapper: no input mutation, no model
call for an empty mask, safe padding for tiny images, and inclusion of soft masks
below 128 when choosing large-image crops. Standard binary-mask processing retains
the previous crop/pad/composite behavior.
## Validation
The lightweight suite exercises processing and download integrity without PyTorch
or real model weights. The separate application job installs the complete manifest
on Windows/Linux/macOS, runs `pip check`, checks imports/CLI help, and executes a
small generated TorchScript model plus image/video routing tests. Neither job
downloads production weights or certifies GPU execution.
Changes to model execution also need a separately recorded real-weight comparison:
model digest, runtime versions, device, fixture dimensions/masks and differences
from the reference. Keep fixtures synthetic or redistributable. GPU coverage must
name the tested device; it does not imply universal GPU or MPS compatibility.
+5
View File
@@ -0,0 +1,5 @@
"""Small LaMA inference adapter; no IOPaint runtime dependency."""
from .processing import InpaintProcessor
__all__ = ["InpaintProcessor"]
+30
View File
@@ -0,0 +1,30 @@
"""Explicit model preparation: python -m lama_inpaint download [--offline]."""
import argparse
import sys
from .model_store import ModelError, ensure_model
def main():
"""Verify/download the fixed artifact, returning a nonzero status on failure."""
parser = argparse.ArgumentParser(description="Prepare verified LaMA model weights")
parser.add_argument("command", choices=["download"])
parser.add_argument("--cache-dir")
parser.add_argument(
"--offline",
action="store_true",
help="Verify cached weights without network access",
)
args = parser.parse_args()
try:
path = ensure_model(args.cache_dir, download=not args.offline)
except (ModelError, OSError) as exc:
print(f"LaMA model preparation failed: {exc}", file=sys.stderr)
return 1
print(f"LaMA model verified: {path}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
+114
View File
@@ -0,0 +1,114 @@
"""Verified, atomic download and reuse of the fixed LaMA TorchScript artifact."""
import hashlib
from http.client import HTTPException
import os
from pathlib import Path
import tempfile
import time
from urllib.request import urlopen
from filelock import FileLock, Timeout
MODEL_URL = (
"https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt"
)
MODEL_SHA256 = "344c77bbcb158f17dd143070d1e789f38a66c04202311ae3a258ef66667a9ea9"
MODEL_SIZE = 205669692
MODEL_NAME = "big-lama.pt"
class ModelError(RuntimeError):
"""A model could not be obtained or verified; no unverified weights are loaded."""
def default_cache_dir():
"""Honor the standard Torch cache variables and reuse existing LaMA weights."""
torch_home = os.environ.get("TORCH_HOME")
if torch_home:
return Path(torch_home).expanduser() / "hub" / "checkpoints"
cache = Path(os.environ.get("XDG_CACHE_HOME", "~/.cache")).expanduser()
return cache / "torch" / "hub" / "checkpoints"
def verify_stream(stream):
"""Check an open artifact's exact size and SHA-256, then rewind it."""
stream.seek(0)
digest = hashlib.sha256()
size = 0
while chunk := stream.read(1024 * 1024):
size += len(chunk)
if size > MODEL_SIZE:
raise ModelError("LaMA model exceeds the expected artifact size.")
digest.update(chunk)
stream.seek(0)
if size != MODEL_SIZE or digest.hexdigest() != MODEL_SHA256:
raise ModelError("LaMA model checksum or size mismatch; refusing to load it.")
def _is_valid(path):
"""Return whether an existing artifact matches the pinned size and digest."""
try:
with path.open("rb") as stream:
verify_stream(stream)
return True
except (FileNotFoundError, ModelError):
return False
def ensure_model(cache_dir=None, *, download=True):
"""Return verified weights, downloading atomically when allowed.
Downloads use a unique temporary file and a per-artifact cross-process lock.
A failed transfer never replaces an existing file. Offline mode never opens
a network connection. The URL and expected digest are deliberately fixed.
"""
cache = Path(cache_dir) if cache_dir is not None else default_cache_dir()
target = cache / MODEL_NAME
if not download:
if not _is_valid(target):
raise ModelError(
f"No verified LaMA model at {target}. Run python -m lama_inpaint download first."
)
return target
cache.mkdir(parents=True, exist_ok=True)
try:
with FileLock(str(target) + ".lock", timeout=600):
if _is_valid(target):
return target
_download(target)
except Timeout as exc:
raise ModelError("Timed out waiting for another LaMA model download.") from exc
return target
def _download(target):
"""Publish only a complete verified transfer; retain the old file on failure."""
temporary = None
try:
with tempfile.NamedTemporaryFile(
dir=target.parent, prefix=".lama-", suffix=".part", delete=False
) as output:
temporary = Path(output.name)
deadline = time.monotonic() + 600
size = 0
with urlopen(MODEL_URL, timeout=30) as response:
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > MODEL_SIZE or time.monotonic() > deadline:
raise ModelError(
"LaMA download exceeded its size or time limit."
)
output.write(chunk)
output.flush()
os.fsync(output.fileno())
with temporary.open("rb") as stream:
verify_stream(stream)
os.replace(temporary, target)
except (OSError, ValueError, HTTPException) as exc:
raise ModelError(
f"Could not download LaMA weights: {exc}. Retry python -m lama_inpaint download."
) from exc
finally:
if temporary is not None:
temporary.unlink(missing_ok=True)
+103
View File
@@ -0,0 +1,103 @@
"""LaMA crop/pad/composite processing with an injectable RGB predictor.
Adapted from IOPaint's LaMa/InpaintModel and image helpers (Apache-2.0).
See THIRD_PARTY_NOTICES.md for the source revision and license.
"""
import cv2
import numpy as np
class InpaintProcessor:
"""Process uint8 RGB images and uint8 masks; return independent uint8 BGR.
The predictor receives padded RGB and single-channel masks and returns RGB.
A positive mask value selects model input; its 0..255 value controls final
blending. Large images use the existing 800-pixel crop trigger and 64-pixel
context margin. Predictions always see the original input, including when
context crops overlap. Neither caller-owned input is modified.
"""
def __init__(self, predictor, *, crop_trigger=800, crop_margin=64):
"""Bind an RGB predictor and validate the positive crop trigger and margin."""
if not isinstance(crop_trigger, int) or crop_trigger < 1:
raise ValueError("crop_trigger must be a positive integer")
if not isinstance(crop_margin, int) or crop_margin < 1:
raise ValueError("crop_margin must be a positive integer")
self.predictor = predictor
self.crop_trigger = crop_trigger
self.crop_margin = crop_margin
def __call__(self, image, mask):
"""Validate RGB/mask arrays and return a new BGR composite without input mutation."""
if (
not isinstance(image, np.ndarray)
or image.dtype != np.uint8
or image.ndim != 3
or image.shape[2] != 3
):
raise ValueError("image must be an HxWx3 uint8 RGB array")
if (
not isinstance(mask, np.ndarray)
or mask.dtype != np.uint8
or mask.shape != image.shape[:2]
):
raise ValueError("mask must be an HxW uint8 array matching the image")
height, width = mask.shape
if not height or not width:
raise ValueError("image and mask must not be empty")
result = image[:, :, ::-1].copy()
if not mask.any():
return result
if max(height, width) <= self.crop_trigger:
return self._forward(image, mask)
binary = (mask > 0).astype(np.uint8)
contours, _ = cv2.findContours(
binary, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
)
for contour in contours:
x, y, w, h = cv2.boundingRect(contour)
left, right = self._bounds(x, w, width)
top, bottom = self._bounds(y, h, height)
crop = self._forward(
image[top:bottom, left:right], mask[top:bottom, left:right]
)
result[top:bottom, left:right] = crop
return result
def _bounds(self, start, length, limit):
# Preserve IOPaint's centered crop sizing, including odd-sized boxes.
"""Return a clipped context interval, preserving legacy odd-box centering."""
center = (2 * start + length) // 2
half = (length + 2 * self.crop_margin) // 2
lower, upper = center - half, center + half
left, right = max(lower, 0), min(upper, limit)
if lower < 0:
right += -lower
if upper > limit:
left -= upper - limit
return max(left, 0), min(right, limit)
def _forward(self, image, mask):
"""Symmetrically pad one crop, predict RGB, and composite its original-size BGR result."""
height, width = mask.shape
# Reflection padding inside the network needs more than a single feature
# pixel after downsampling. Keep tiny inputs safe without resizing them.
padded_height = max(32, ((height + 7) // 8) * 8)
padded_width = max(32, ((width + 7) // 8) * 8)
padding = ((0, padded_height - height), (0, padded_width - width))
padded_image = np.pad(image, (*padding, (0, 0)), mode="symmetric")
padded_mask = np.pad(mask, padding, mode="symmetric")
prediction = self.predictor(padded_image, padded_mask)
if (
not isinstance(prediction, np.ndarray)
or prediction.shape != padded_image.shape
or prediction.dtype != np.uint8
):
raise ValueError(
"LaMA predictor must return a uint8 RGB array matching its padded input"
)
prediction = prediction[:height, :width, ::-1]
# float64 matches the previous IOPaint composite and truncation behavior.
alpha = mask[:, :, None] / 255.0
return (prediction * alpha + image[:, :, ::-1] * (1 - alpha)).astype(np.uint8)
+48
View File
@@ -0,0 +1,48 @@
"""TorchScript LaMA execution, independent of detection and GUI frameworks."""
import numpy as np
import torch
from .model_store import ModelError, ensure_model, verify_stream
from .processing import InpaintProcessor
class LamaInpaint(InpaintProcessor):
"""Load verified LaMA on CPU/CUDA and preserve the application's BGR API."""
def __init__(self, device="cpu", *, cache_dir=None, download=True):
"""Verify the pinned artifact and load an evaluation-only CPU or CUDA model."""
self.device = torch.device(device)
if self.device.type not in {"cpu", "cuda"}:
raise ValueError(
"LaMA currently supports CPU and CUDA; MPS is not validated"
)
if self.device.type == "cuda" and not torch.cuda.is_available():
raise RuntimeError("CUDA was requested for LaMA but PyTorch cannot use it")
path = ensure_model(cache_dir, download=download)
try:
# Verify and load through the same handle: never deserialize a replaced path.
with path.open("rb") as stream:
verify_stream(stream)
self.model = (
torch.jit.load(stream, map_location="cpu").eval().to(self.device)
)
except (OSError, RuntimeError) as exc:
raise ModelError(
f"Could not load verified LaMA weights on {self.device}: {exc}"
) from exc
super().__init__(self._predict)
@torch.inference_mode()
def _predict(self, image, mask):
"""Run normalized NCHW tensors and validate finite RGB output before quantization."""
rgb = np.ascontiguousarray(image.transpose(2, 0, 1), dtype=np.float32) / 255.0
selected = np.ascontiguousarray(mask[None] > 0, dtype=np.int64)
result = self.model(
torch.from_numpy(rgb)[None].to(self.device),
torch.from_numpy(selected)[None].to(self.device),
)
if result.shape != (1, 3, *mask.shape) or not torch.isfinite(result).all():
raise RuntimeError("LaMA returned invalid dimensions or non-finite pixels")
rgb = result[0].permute(1, 2, 0).cpu().numpy()
return np.clip(rgb * 255, 0, 255).astype(np.uint8)
+201
View File
@@ -0,0 +1,201 @@
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+8 -53
View File
@@ -5,14 +5,8 @@ import cv2
import numpy as np
from PIL import Image, ImageDraw
# Monkey-patch: cached_download was removed in huggingface_hub 0.24, add compatibility shim
import huggingface_hub
if not hasattr(huggingface_hub, 'cached_download'):
huggingface_hub.cached_download = huggingface_hub.hf_hub_download
from transformers import AutoProcessor, Florence2ForConditionalGeneration
from iopaint.model_manager import ModelManager
from iopaint.schema import HDStrategy, LDMSampler, InpaintRequest as Config
from lama_inpaint.runtime import LamaInpaint
import torch
from torch.nn import Module
import tqdm
@@ -29,43 +23,11 @@ except ImportError:
MatLike = np.ndarray
def download_lama_model():
"""Download LaMA model using iopaint."""
logger.info("Downloading LaMA model... (this may take a few minutes)")
print("Downloading LaMA model (~196MB)... Please wait.")
result = subprocess.run(
[sys.executable, "-m", "iopaint", "download", "--model", "lama"],
capture_output=False, # Show download progress
text=True
)
if result.returncode != 0:
logger.error("Failed to download LaMA model")
return False
logger.info("LaMA model downloaded successfully")
print("LaMA model downloaded!")
return True
def load_lama_model(device):
"""Load LaMA model, downloading if necessary."""
try:
return ModelManager(name="lama", device=device)
except NotImplementedError as e:
if "Unsupported model: lama" in str(e):
print("LaMA model not available, attempting to download...")
if download_lama_model():
# Re-import to refresh model registry
import importlib
import iopaint.model
importlib.reload(iopaint.model)
# Try again
return ModelManager(name="lama", device=device)
else:
raise RuntimeError("Failed to download LaMA model. Please run manually: python\\python.exe -m iopaint download --model lama")
raise
"""Load checksum-verified LaMA weights using the standalone adapter."""
logger.info("Loading LaMA (verifying cached weights or downloading if missing)")
return LamaInpaint(device)
class TaskType(str, Enum):
OPEN_VOCAB_DETECTION = "<OPEN_VOCABULARY_DETECTION>"
@@ -156,16 +118,9 @@ def detect_only(image: MatLike, model: Florence2ForConditionalGeneration, proces
return results
def process_image_with_lama(image: MatLike, mask: MatLike, model_manager: ModelManager):
config = Config(
ldm_steps=50,
ldm_sampler=LDMSampler.ddim,
hd_strategy=HDStrategy.CROP,
hd_strategy_crop_margin=64,
hd_strategy_crop_trigger_size=800,
hd_strategy_resize_limit=1600,
)
result = model_manager(image, mask, config)
def process_image_with_lama(image: MatLike, mask: MatLike, model_manager: LamaInpaint):
"""Return the shared LaMA adapter result as a uint8 BGR image."""
result = model_manager(image, mask)
if result.dtype in [np.float64, np.float32]:
result = np.clip(result, 0, 255).astype(np.uint8)
+12
View File
@@ -0,0 +1,12 @@
# CLI and model runtime. Install a matching torch/torchvision build for your device first.
torch>=2.4.0
torchvision>=0.19.0
# Native Florence-2 API; pre-4.56 releases do not provide the required model class.
transformers>=4.56.0,<5
numpy>=1.26,<2
opencv-python-headless>=4.8.0,<4.12.0
Pillow>=10.0.0
filelock>=3.16
loguru
click
tqdm
+2
View File
@@ -4,3 +4,5 @@ ruff==0.16.7
numpy==2.4.6
Pillow==12.3.0
matplotlib==3.10.8
opencv-python-headless==4.11.0.86
filelock==3.32.3
+4 -24
View File
@@ -1,27 +1,7 @@
# Core AI/ML - CUDA 12.4 versions
--extra-index-url https://download.pytorch.org/whl/cu124
torch>=2.4.0
torchvision>=0.19.0
transformers>=4.50.0
diffusers>=0.30.0
numpy<2
-r requirements-core.txt
# Image/Video processing
opencv-python-headless>=4.8.0,<4.12.0
Pillow>=10.0.0
# Inpainting core (iopaint installed separately with --no-deps to avoid resolver conflicts)
# GUI (PyWebview for brainrot HTML UI)
pywebview>=4.0
# Utilities
loguru
click
tqdm
# Desktop UI. Linux needs a concrete backend; Qt wheels avoid a GTK source build.
pywebview>=4.0; sys_platform != "linux"
pywebview[qt]>=4.0; sys_platform == "linux"
psutil
pyyaml
# qt and gtk gui
PyGObject
qtpy
+55
View File
@@ -0,0 +1,55 @@
"""Check that the installed desktop backend can call Python from JavaScript."""
import os
import threading
import time
import webview
class Bridge:
"""Minimal API used only by this installation check."""
def ping(self):
"""Return a value for the browser to expose after resolving its API call."""
return "bridge-ok"
completed = threading.Event()
finished = threading.Event()
window = webview.create_window(
"Backend verification",
html="""<html><body><script>
window.addEventListener('pywebviewready', async () => {
window.bridgeResult = await window.pywebview.api.ping();
});
</script></body></html>""",
js_api=Bridge(),
hidden=True,
)
def check_bridge():
"""Close only after the browser has received its Python API response."""
deadline = time.monotonic() + 45
while time.monotonic() < deadline:
if window.evaluate_js("window.bridgeResult || null") == "bridge-ok":
completed.set()
break
time.sleep(0.1)
window.destroy()
def watchdog():
"""Fail even if startup, a native JS call or window teardown hangs."""
if not finished.wait(60):
print("Backend bridge/startup/teardown timed out", flush=True)
os._exit(1)
threading.Thread(target=watchdog, daemon=True).start()
webview.start(check_bridge)
finished.set()
if not completed.is_set():
raise SystemExit("Backend closed before completing its bridge check")
print("Desktop backend JavaScript/Python bridge passed")
+21 -75
View File
@@ -82,6 +82,13 @@ if not exist "%PYTHON_EXE%" (
echo [OK] Python found
)
:: Include the application root for embedded Python package imports.
set PTH_FILE=%PYTHON_DIR%\python312._pth
if exist "%PTH_FILE%" (
findstr /l /x /c:".." "%PTH_FILE%" >nul
if errorlevel 1 echo ..>> "%PTH_FILE%"
)
echo.
echo [*] Installing dependencies...
echo This takes 5-10 minutes. Please wait...
@@ -97,9 +104,9 @@ if "%CHINA_MODE%"=="1" (
:: Install base dependencies
echo [*] Installing base packages...
if "%CHINA_MODE%"=="1" (
"%PYTHON_EXE%" -m pip install --upgrade -r requirements.txt --no-cache-dir --use-deprecated=legacy-resolver %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install --upgrade -r requirements.txt --no-cache-dir %PIP_MIRROR%
) else (
"%PYTHON_EXE%" -m pip install --upgrade -r requirements.txt --no-cache-dir --use-deprecated=legacy-resolver
"%PYTHON_EXE%" -m pip install --upgrade -r requirements.txt --no-cache-dir
)
if errorlevel 1 (
echo [X] Failed to install base dependencies
@@ -107,89 +114,28 @@ if errorlevel 1 (
exit /b 1
)
:: Verify key packages
"%PYTHON_EXE%" -c "import torch; import transformers; import webview; import cv2; print('OK')" >nul 2>&1
:: Verify the full application environment, not only selected imports.
"%PYTHON_EXE%" -m pip check
if errorlevel 1 (
echo [X] Failed to verify base packages
echo [X] Dependency verification failed. Use a fresh application environment.
pause
exit /b 1
)
echo [OK] Base packages installed
:: Install iopaint without dependencies
echo [*] Installing iopaint...
if "%CHINA_MODE%"=="1" (
"%PYTHON_EXE%" -m pip install --upgrade iopaint --no-deps --no-cache-dir %PIP_MIRROR%
) else (
"%PYTHON_EXE%" -m pip install --upgrade iopaint --no-deps --no-cache-dir
)
"%PYTHON_EXE%" -c "import remwm; import webview; import yaml; import psutil"
if errorlevel 1 (
echo [X] Failed to install iopaint
pause
exit /b 1
)
echo [OK] iopaint installed
:: Install iopaint dependencies manually
echo [*] Installing iopaint dependencies...
if "%CHINA_MODE%"=="1" (
"%PYTHON_EXE%" -m pip install pydantic typer einops omegaconf easydict yacs --no-cache-dir %PIP_MIRROR%
) else (
"%PYTHON_EXE%" -m pip install pydantic typer einops omegaconf easydict yacs --no-cache-dir
)
if errorlevel 1 (
echo [X] Failed to install iopaint dependencies
echo [X] Failed to import application packages
pause
exit /b 1
)
:: Verify iopaint dependencies
"%PYTHON_EXE%" -c "import pydantic; import typer; import einops; import omegaconf; import easydict; import yacs; print('OK')" >nul 2>&1
:: Shared checksum-verified, atomic model download.
echo [*] Preparing LaMA model (196MB)...
if "%CHINA_MODE%"=="1" echo If GitHub is blocked, preseed the verified cache: docs/lama-runtime.md#restricted-networks
"%PYTHON_EXE%" -m lama_inpaint download
if errorlevel 1 (
echo [!] iopaint dependencies verification failed, attempting reinstall...
if "%CHINA_MODE%"=="1" (
"%PYTHON_EXE%" -m pip install pydantic --no-cache-dir --force-reinstall %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install typer --no-cache-dir --force-reinstall %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install einops --no-cache-dir --force-reinstall %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install omegaconf --no-cache-dir --force-reinstall %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install easydict --no-cache-dir --force-reinstall %PIP_MIRROR%
"%PYTHON_EXE%" -m pip install yacs --no-cache-dir --force-reinstall %PIP_MIRROR%
) else (
"%PYTHON_EXE%" -m pip install pydantic --no-cache-dir --force-reinstall
"%PYTHON_EXE%" -m pip install typer --no-cache-dir --force-reinstall
"%PYTHON_EXE%" -m pip install einops --no-cache-dir --force-reinstall
"%PYTHON_EXE%" -m pip install omegaconf --no-cache-dir --force-reinstall
"%PYTHON_EXE%" -m pip install easydict --no-cache-dir --force-reinstall
"%PYTHON_EXE%" -m pip install yacs --no-cache-dir --force-reinstall
)
"%PYTHON_EXE%" -c "import pydantic; import typer; import einops; import omegaconf; import easydict; import yacs; print('OK')" >nul 2>&1
if errorlevel 1 (
echo [X] Could not install iopaint dependencies
echo Please try running: pip install pydantic typer einops omegaconf easydict yacs
pause
exit /b 1
)
)
echo [OK] iopaint dependencies installed and verified
:: Download LaMA model
echo.
echo [*] Downloading LaMA model (196MB)...
set LAMA_DIR=%USERPROFILE%\.cache\torch\hub\checkpoints
set LAMA_FILE=%LAMA_DIR%\big-lama.pt
if not exist "%LAMA_FILE%" (
if not exist "%LAMA_DIR%" mkdir "%LAMA_DIR%"
powershell -Command "Invoke-WebRequest -Uri 'https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt' -OutFile '%LAMA_FILE%' -UseBasicParsing"
if exist "%LAMA_FILE%" (
echo [OK] LaMA model ready
) else (
echo [!] Warning: Could not download LaMA model
echo It will be downloaded on first use
)
) else (
echo [OK] LaMA model already exists
echo [X] Could not prepare verified LaMA weights. Fix the error above and retry.
pause
exit /b 1
)
:: Download Florence-2 model
+37 -128
View File
@@ -1,6 +1,8 @@
# WatermarkRemover-AI Setup Script
$Host.UI.RawUI.WindowTitle = "WatermarkRemover-AI Setup"
Set-Location -LiteralPath $PSScriptRoot
$PYTHON_VERSION = "3.12.7"
$PYTHON_DIR = "python"
$PYTHON_EXE = "$PYTHON_DIR\python.exe"
@@ -104,6 +106,15 @@ else {
Write-Host " [OK] Python found" -ForegroundColor Green
}
# Make local packages importable in fresh and existing embedded runtimes.
$pthFile = Join-Path $PYTHON_DIR "python312._pth"
if (Test-Path -LiteralPath $pthFile) {
$pthLines = @(Get-Content -LiteralPath $pthFile)
if ($pthLines -notcontains "..") {
Add-Content -LiteralPath $pthFile -Value "`n.."
}
}
Write-Host ""
Write-Host " [*] Installing dependencies..." -ForegroundColor Cyan
Write-Host " This takes 5-10 minutes. Chill and learn something!" -ForegroundColor Magenta
@@ -118,14 +129,16 @@ if ($CHINA_MODE) {
& $PYTHON_EXE -m pip install --upgrade pip setuptools wheel 2>&1 | Out-Null
}
# Install base deps with tips (legacy resolver to ignore conflicts)
# Install dependencies with the normal resolver and retain the installation tips.
if ($CHINA_MODE) {
# For China: use Tsinghua mirror, skip PyTorch extra-index-url from requirements.txt
$process = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "-r", "requirements.txt", "--no-cache-dir", "--use-deprecated=legacy-resolver", "-i", $PIP_INDEX_URL, "--trusted-host", "pypi.tuna.tsinghua.edu.cn" -NoNewWindow -PassThru
# Use the selected package mirror.
$process = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "-r", "requirements.txt", "--no-cache-dir", "-i", $PIP_INDEX_URL, "--trusted-host", "pypi.tuna.tsinghua.edu.cn" -NoNewWindow -PassThru
} else {
$process = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "-r", "requirements.txt", "--no-cache-dir", "--use-deprecated=legacy-resolver" -NoNewWindow -PassThru
$process = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "-r", "requirements.txt", "--no-cache-dir" -NoNewWindow -PassThru
}
# Retain the native process handle so ExitCode remains available after exit.
$processHandle = $process.Handle
$lastTipTime = Get-Date
$currentTip = Get-Random -Maximum $tips.Count
@@ -145,138 +158,32 @@ while (-not $process.HasExited) {
Write-Host "`r "
# Legacy resolver can return non-zero even on success, so verify key packages
$verifyResult = & $PYTHON_EXE -c "import torch; import transformers; import webview; import cv2; print('OK')" 2>&1
if ($verifyResult -ne "OK") {
if ($process.ExitCode -ne 0) {
Write-Host ""
Write-Host " [X] Failed to install dependencies" -ForegroundColor Red
Read-Host " Press Enter to exit"
exit 1
}
}
# Install iopaint separately without pulling its deps (we already have ours)
Write-Host " [*] Installing iopaint (no deps)..." -ForegroundColor Cyan
if ($CHINA_MODE) {
$iopaintProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "iopaint", "--no-deps", "--no-cache-dir", "-i", $PIP_INDEX_URL, "--trusted-host", "pypi.tuna.tsinghua.edu.cn" -NoNewWindow -PassThru
} else {
$iopaintProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "--upgrade", "iopaint", "--no-deps", "--no-cache-dir" -NoNewWindow -PassThru
}
$iopaintProcess.WaitForExit()
if ($iopaintProcess.ExitCode -ne 0) {
Write-Host ""
Write-Host " [X] Failed to install iopaint" -ForegroundColor Red
Read-Host " Press Enter to exit"
$process.WaitForExit()
if ($process.ExitCode -ne 0) {
Write-Host " [X] Failed to install dependencies" -ForegroundColor Red
exit 1
}
Write-Host " [OK] iopaint installed" -ForegroundColor Green
# Install iopaint's required dependencies manually (subset needed for LaMA inpainting)
Write-Host " [*] Installing iopaint dependencies..." -ForegroundColor Cyan
if ($CHINA_MODE) {
$iopaintDepsProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "pydantic", "typer", "einops", "omegaconf", "easydict", "yacs", "--no-cache-dir", "-i", $PIP_INDEX_URL, "--trusted-host", "pypi.tuna.tsinghua.edu.cn" -NoNewWindow -PassThru
} else {
$iopaintDepsProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-m", "pip", "install", "pydantic", "typer", "einops", "omegaconf", "easydict", "yacs", "--no-cache-dir" -NoNewWindow -PassThru
}
$iopaintDepsProcess.WaitForExit()
if ($iopaintDepsProcess.ExitCode -ne 0) {
Write-Host ""
Write-Host " [X] Failed to install iopaint dependencies" -ForegroundColor Red
Read-Host " Press Enter to exit"
& $PYTHON_EXE -m pip check
if ($LASTEXITCODE -ne 0) {
Write-Host " [X] Dependency verification failed. Use a fresh application environment." -ForegroundColor Red
exit 1
}
# Verify iopaint dependencies are properly installed
Write-Host " [*] Verifying iopaint dependencies..." -ForegroundColor Cyan
$verifyIopaint = & $PYTHON_EXE -c "import pydantic; import typer; import einops; import omegaconf; import easydict; import yacs; print('OK')" 2>&1
if ($verifyIopaint -ne "OK") {
Write-Host ""
Write-Host " [X] iopaint dependencies verification failed" -ForegroundColor Red
Write-Host " Missing modules detected. Attempting reinstall..." -ForegroundColor Yellow
# Try installing one by one to identify issues
$deps = @("pydantic", "typer", "einops", "omegaconf", "easydict", "yacs")
foreach ($dep in $deps) {
if ($CHINA_MODE) {
& $PYTHON_EXE -m pip install $dep --no-cache-dir -i $PIP_INDEX_URL --trusted-host pypi.tuna.tsinghua.edu.cn 2>&1 | Out-Null
} else {
& $PYTHON_EXE -m pip install $dep --no-cache-dir 2>&1 | Out-Null
}
}
# Verify again
$verifyAgain = & $PYTHON_EXE -c "import pydantic; import typer; import einops; import omegaconf; import easydict; import yacs; print('OK')" 2>&1
if ($verifyAgain -ne "OK") {
Write-Host " [X] Could not install iopaint dependencies" -ForegroundColor Red
Write-Host " Please try running: pip install pydantic typer einops omegaconf easydict yacs" -ForegroundColor Yellow
Read-Host " Press Enter to exit"
exit 1
}
& $PYTHON_EXE -c "import remwm; import webview; import yaml; import psutil"
if ($LASTEXITCODE -ne 0) {
Write-Host " [X] Failed to import application packages" -ForegroundColor Red
exit 1
}
Write-Host " [OK] Dependencies installed and verified" -ForegroundColor Green
# Download LaMA model directly from GitHub (avoids iopaint CLI dependency on fastapi)
Write-Host ""
Write-Host " [*] Downloading AI model (196MB)..." -ForegroundColor Cyan
Write-Host ""
Write-Host " Did you know?" -ForegroundColor DarkGray
Write-Host ""
$lamaDir = Join-Path $env:USERPROFILE ".cache\torch\hub\checkpoints"
$lamaFile = Join-Path $lamaDir "big-lama.pt"
$lamaUrl = "https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt"
if (-not (Test-Path $lamaFile)) {
# Create directory if needed
if (-not (Test-Path $lamaDir)) {
New-Item -ItemType Directory -Path $lamaDir -Force | Out-Null
}
try {
# Show tips while downloading
$job = Start-Job -ScriptBlock {
param($url, $dest)
Invoke-WebRequest -Uri $url -OutFile $dest -UseBasicParsing
} -ArgumentList $lamaUrl, $lamaFile
$lastTipTime = Get-Date
while ($job.State -eq "Running") {
$now = Get-Date
if (($now - $lastTipTime).TotalSeconds -ge 5) {
$tip = $tips[$currentTip]
$line = " $($tip.icon) $($tip.text)"
$line = $line.PadRight(90)
Write-Host "`r$line" -ForegroundColor $tip.color -NoNewline
$currentTip = ($currentTip + 1) % $tips.Count
$lastTipTime = $now
}
Start-Sleep -Milliseconds 300
}
Write-Host "`r "
$result = Receive-Job -Job $job
Remove-Job -Job $job
if (Test-Path $lamaFile) {
Write-Host " [OK] LaMA model ready" -ForegroundColor Green
} else {
Write-Host " [!] Warning: Could not download LaMA model" -ForegroundColor Yellow
Write-Host " It will be downloaded on first use" -ForegroundColor Yellow
}
}
catch {
Write-Host " [!] Warning: Could not download LaMA model" -ForegroundColor Yellow
Write-Host " It will be downloaded on first use" -ForegroundColor Yellow
}
# All installers use the same checksum-verified, atomic model download.
Write-Host " [*] Preparing LaMA model (196MB)..." -ForegroundColor Cyan
if ($CHINA_MODE) {
Write-Host " If GitHub is blocked, preseed the verified cache: docs/lama-runtime.md#restricted-networks" -ForegroundColor DarkGray
}
else {
Write-Host " [OK] LaMA model already exists" -ForegroundColor Green
& $PYTHON_EXE -m lama_inpaint download
if ($LASTEXITCODE -ne 0) {
Write-Host " [X] Could not prepare verified LaMA weights. Fix the error above and retry." -ForegroundColor Red
exit 1
}
# Download Florence-2 model for watermark detection
@@ -306,6 +213,7 @@ print('FLORENCE_OK')
}
$florenceProcess = Start-Process -FilePath $PYTHON_EXE -ArgumentList "-c", "`"$florenceScript`"" -NoNewWindow -PassThru
$florenceHandle = $florenceProcess.Handle
$lastTipTime = Get-Date
while (-not $florenceProcess.HasExited) {
@@ -324,6 +232,7 @@ while (-not $florenceProcess.HasExited) {
Write-Host "`r "
$florenceProcess.WaitForExit()
if ($florenceProcess.ExitCode -ne 0) {
Write-Host " [!] Warning: Could not download Florence-2 model" -ForegroundColor Yellow
Write-Host " It will be downloaded on first use" -ForegroundColor Yellow
+16 -52
View File
@@ -1,5 +1,6 @@
#!/usr/bin/env bash
set -e
cd -- "$(dirname -- "${BASH_SOURCE[0]}")"
echo ""
echo " ============================================="
@@ -83,74 +84,37 @@ echo " [*] Installing PyTorch..."
if [ "$OS_TYPE" == "macos" ]; then
# macOS: Install from main PyPI (supports MPS on Apple Silicon)
if [ "$CHINA_MODE" == "1" ]; then
pip install torch>=2.4.0 torchvision>=0.19.0 --no-cache-dir $PIP_MIRROR -q
pip install "torch>=2.4.0" "torchvision>=0.19.0" --no-cache-dir $PIP_MIRROR -q
else
pip install torch>=2.4.0 torchvision>=0.19.0 --no-cache-dir -q
pip install "torch>=2.4.0" "torchvision>=0.19.0" --no-cache-dir -q
fi
echo " [OK] PyTorch installed (MPS support on Apple Silicon)"
else
# Linux: Try CUDA first, fallback to CPU
# Select one wheel index; mixing PyPI/mirrors can silently choose a CPU build.
if command -v nvidia-smi &> /dev/null; then
echo " [*] NVIDIA GPU detected, installing CUDA version..."
if [ "$CHINA_MODE" == "1" ]; then
pip install torch>=2.4.0 torchvision>=0.19.0 --extra-index-url https://download.pytorch.org/whl/cu124 --no-cache-dir $PIP_MIRROR -q
else
pip install torch>=2.4.0 torchvision>=0.19.0 --extra-index-url https://download.pytorch.org/whl/cu124 --no-cache-dir -q
fi
python -m pip --isolated install "torch>=2.4.0" "torchvision>=0.19.0" --index-url https://download.pytorch.org/whl/cu124 --no-cache-dir -q
echo " [OK] PyTorch installed (CUDA 12.4)"
else
echo " [*] No NVIDIA GPU detected, installing CPU version..."
if [ "$CHINA_MODE" == "1" ]; then
pip install torch>=2.4.0 torchvision>=0.19.0 --no-cache-dir $PIP_MIRROR -q
else
pip install torch>=2.4.0 torchvision>=0.19.0 --no-cache-dir -q
fi
python -m pip --isolated install "torch>=2.4.0" "torchvision>=0.19.0" --index-url https://download.pytorch.org/whl/cpu --no-cache-dir -q
echo " [OK] PyTorch installed (CPU)"
fi
fi
# Install other dependencies (without torch lines)
echo " [*] Installing other dependencies..."
if [ "$CHINA_MODE" == "1" ]; then
pip install transformers>=4.50.0 diffusers>=0.30.0 "numpy<2" --no-cache-dir $PIP_MIRROR -q
pip install "opencv-python-headless>=4.8.0,<4.12.0" "Pillow>=10.0.0" --no-cache-dir $PIP_MIRROR -q
pip install pywebview>=4.0 --no-cache-dir $PIP_MIRROR -q
pip install loguru click tqdm psutil pyyaml --no-cache-dir $PIP_MIRROR -q
else
pip install transformers>=4.50.0 diffusers>=0.30.0 "numpy<2" --no-cache-dir -q
pip install "opencv-python-headless>=4.8.0,<4.12.0" "Pillow>=10.0.0" --no-cache-dir -q
pip install pywebview>=4.0 --no-cache-dir -q
pip install loguru click tqdm psutil pyyaml --no-cache-dir -q
fi
# Use the same dependency manifest as the Windows installers and manual installs.
echo " [*] Installing application dependencies..."
python -m pip install -r requirements.txt --no-cache-dir $PIP_MIRROR
python -m pip check
python -c "import remwm; import webview; import yaml; import psutil"
echo " [OK] Dependencies installed and verified"
# Install iopaint separately (no deps to avoid conflicts)
echo " [*] Installing iopaint..."
# Shared checksum-verified, atomic download; failure must not look like success.
echo " [*] Preparing LaMA model (~196MB)..."
if [ "$CHINA_MODE" == "1" ]; then
pip install iopaint --no-deps --no-cache-dir $PIP_MIRROR -q
else
pip install iopaint --no-deps --no-cache-dir -q
fi
# Install iopaint's required dependencies manually (subset needed for LaMA inpainting)
echo " [*] Installing iopaint dependencies..."
if [ "$CHINA_MODE" == "1" ]; then
pip install pydantic typer einops omegaconf easydict yacs --no-cache-dir $PIP_MIRROR -q
else
pip install pydantic typer einops omegaconf easydict yacs --no-cache-dir -q
fi
echo " [OK] Dependencies installed"
# Download LaMA model directly from GitHub (avoids iopaint CLI dependency on fastapi)
echo " [*] Downloading LaMA model (~196MB)..."
LAMA_DIR="$HOME/.cache/torch/hub/checkpoints"
LAMA_FILE="$LAMA_DIR/big-lama.pt"
if [ ! -f "$LAMA_FILE" ]; then
mkdir -p "$LAMA_DIR"
curl -L -o "$LAMA_FILE" "https://github.com/Sanster/models/releases/download/add_big_lama/big-lama.pt" || echo " [!] LaMA download failed, will retry on first use"
echo " [OK] LaMA model downloaded"
else
echo " [OK] LaMA model already exists"
echo " If GitHub is blocked, preseed the verified cache: docs/lama-runtime.md#restricted-networks"
fi
python -m lama_inpaint download
# Download Florence-2 model
echo " [*] Downloading Florence-2 model (~1.5GB)..."
+110
View File
@@ -0,0 +1,110 @@
"""Exercise image and video application paths without detection/model downloads."""
import cv2
import numpy as np
from PIL import Image
import pytest
pytest.importorskip("torch", reason="requires the application runtime test job")
import remwm
from lama_inpaint.processing import InpaintProcessor
@pytest.fixture
def controlled_inpainting(monkeypatch):
"""Keep detection and prediction deterministic and select the no-audio video path."""
def mask(image, *args, **kwargs):
"""Select a fixed rectangle in the supplied image coordinates."""
values = np.zeros((image.height, image.width), np.uint8)
values[16:32, 16:32] = 255
return Image.fromarray(values)
monkeypatch.setattr(remwm, "get_watermark_mask", mask)
def no_ffmpeg(*args, **kwargs):
"""Choose the no-audio fallback deliberately for the silent fixture."""
raise FileNotFoundError("controlled no-audio fixture")
monkeypatch.setattr(remwm.subprocess, "check_output", no_ffmpeg)
return InpaintProcessor(lambda image, mask: np.full_like(image, (255, 0, 0)))
def test_image_application_path_preserves_source_and_colors(
tmp_path, controlled_inpainting
):
"""Check the real image routing path, source preservation and RGB/BGR conversion."""
source, output = tmp_path / "source.png", tmp_path / "output.png"
Image.new("RGB", (64, 48), (20, 60, 100)).save(source)
before = source.read_bytes()
remwm.handle_one(
source,
output,
None,
None,
controlled_inpainting,
"cpu",
False,
10,
"PNG",
False,
)
assert source.read_bytes() == before
with Image.open(output) as image:
assert image.size == (64, 48)
assert image.getpixel((20, 20)) == (255, 0, 0)
assert image.getpixel((0, 0)) == (20, 60, 100)
@pytest.mark.parametrize("detection_skip", [1, 2])
def test_short_video_paths_keep_frames_and_dimensions(
tmp_path, controlled_inpainting, monkeypatch, detection_skip
):
"""Exercise both video routes and check frame count, geometry and channel order."""
source, output = tmp_path / "source.avi", tmp_path / "output.mp4"
writer = cv2.VideoWriter(str(source), cv2.VideoWriter_fourcc(*"MJPG"), 5, (64, 48))
assert writer.isOpened(), "test requires the OpenCV MJPG video writer"
try:
for _ in range(3):
writer.write(np.full((48, 64, 3), (100, 60, 20), np.uint8))
finally:
writer.release()
# Two-pass mode caches detected boxes, while single-pass mode uses the mask.
monkeypatch.setattr(
remwm,
"detect_only",
lambda *a, **k: [
{"bbox": [16, 16, 31, 31], "area_percent": 8, "accepted": True}
],
)
before = source.read_bytes()
remwm.handle_one(
source,
output,
None,
None,
controlled_inpainting,
"cpu",
False,
10,
"MP4",
False,
detection_skip=detection_skip,
)
assert source.read_bytes() == before
capture = cv2.VideoCapture(str(output))
frames = []
try:
while True:
ok, frame = capture.read()
if not ok:
break
frames.append(frame)
finally:
capture.release()
assert len(frames) == 3
for frame in frames:
assert frame.shape == (48, 64, 3)
# Lossy video encoding needs a tolerance; the channel order must survive.
assert frame[23, 23, 2] > 200
assert frame[23, 23, 0] < 30
+146
View File
@@ -0,0 +1,146 @@
"""Download integrity and failure behavior using tiny local byte streams."""
from concurrent.futures import ThreadPoolExecutor
import hashlib
import io
from pathlib import Path
import pytest
from lama_inpaint import model_store as store
@pytest.fixture
def artifact(monkeypatch):
"""Pin verification to tiny controlled bytes so download tests need no real weights."""
data = b"controlled model fixture"
monkeypatch.setattr(store, "MODEL_SIZE", len(data))
monkeypatch.setattr(store, "MODEL_SHA256", hashlib.sha256(data).hexdigest())
return data
def test_download_then_offline_cache_reuse(tmp_path, monkeypatch, artifact):
"""Download once and verify that both online and offline reuse avoid another request."""
calls = []
def response(url, timeout):
"""Record a network attempt and supply the controlled artifact bytes."""
assert url == store.MODEL_URL and timeout == 30
calls.append(url)
return io.BytesIO(artifact)
monkeypatch.setattr(store, "urlopen", response)
target = store.ensure_model(tmp_path)
assert target.read_bytes() == artifact
assert store.ensure_model(tmp_path, download=False) == target
assert store.ensure_model(tmp_path) == target
assert len(calls) == 1
assert not list(tmp_path.glob("*.part"))
@pytest.mark.parametrize("bad", [b"", b"truncated", b"x" * 200])
def test_bad_download_never_published(tmp_path, monkeypatch, artifact, bad):
"""Reject missing, truncated or oversized transfers without leaving a cache artifact."""
monkeypatch.setattr(store, "urlopen", lambda *a, **k: io.BytesIO(bad))
with pytest.raises(store.ModelError):
store.ensure_model(tmp_path)
assert not (tmp_path / store.MODEL_NAME).exists()
assert not list(tmp_path.glob("*.part"))
def test_same_size_tampering_rejected_offline(tmp_path, artifact):
"""Require the digest as well as size when accepting offline weights."""
(tmp_path / store.MODEL_NAME).write_bytes(b"x" * len(artifact))
with pytest.raises(store.ModelError, match="No verified"):
store.ensure_model(tmp_path, download=False)
def test_missing_offline_model_does_not_connect(tmp_path, monkeypatch, artifact):
"""Fail an absent offline cache without opening a network connection."""
def fail(*a, **k):
"""Reject an unexpected network request in an offline test."""
pytest.fail("offline mode must not connect")
monkeypatch.setattr(store, "urlopen", fail)
with pytest.raises(store.ModelError):
store.ensure_model(tmp_path, download=False)
def test_interrupted_download_preserves_old_file(tmp_path, monkeypatch, artifact):
"""Keep the old artifact and remove temporary data after a connection interruption."""
old = tmp_path / store.MODEL_NAME
old.write_bytes(b"old invalid file")
class Interrupted(io.BytesIO):
def read(self, size=-1):
"""Supply one partial chunk, then simulate an interrupted connection."""
if self.tell():
raise OSError("connection interrupted")
return super().read(5)
monkeypatch.setattr(store, "urlopen", lambda *a, **k: Interrupted(artifact))
with pytest.raises(store.ModelError, match="interrupted"):
store.ensure_model(tmp_path)
assert old.read_bytes() == b"old invalid file"
assert not list(tmp_path.glob("*.part"))
def test_corrupt_cache_replaced_only_after_verification(
tmp_path, monkeypatch, artifact
):
"""Replace an invalid existing artifact only with the complete verified fixture."""
target = tmp_path / store.MODEL_NAME
target.write_bytes(b"bad")
monkeypatch.setattr(store, "urlopen", lambda *a, **k: io.BytesIO(artifact))
assert store.ensure_model(tmp_path).read_bytes() == artifact
def test_concurrent_downloads_share_verified_artifact(tmp_path, monkeypatch, artifact):
"""Concurrent callers must obtain one verified download through the artifact lock."""
calls = []
def response(*a, **k):
"""Record a network attempt and supply the controlled artifact bytes."""
calls.append(1)
return io.BytesIO(artifact)
monkeypatch.setattr(store, "urlopen", response)
with ThreadPoolExecutor(max_workers=4) as pool:
paths = list(pool.map(lambda _: store.ensure_model(tmp_path), range(4)))
assert all(p.read_bytes() == artifact for p in paths)
assert len(calls) == 1
def test_torch_cache_environment(monkeypatch, tmp_path):
"""Apply Torch cache precedence before falling back to the XDG cache directory."""
monkeypatch.setenv("TORCH_HOME", str(tmp_path / "custom"))
assert store.default_cache_dir() == tmp_path / "custom" / "hub" / "checkpoints"
monkeypatch.delenv("TORCH_HOME")
monkeypatch.setenv("XDG_CACHE_HOME", str(tmp_path))
assert store.default_cache_dir() == tmp_path / "torch" / "hub" / "checkpoints"
def test_verification_rewinds_stream(artifact):
"""Leave a verified open stream ready for deserialization from its beginning."""
stream = io.BytesIO(artifact)
stream.seek(4)
store.verify_stream(stream)
assert stream.tell() == 0
def test_incomplete_http_response_is_reported_and_cleaned(
tmp_path, monkeypatch, artifact
):
"""Convert an incomplete HTTP response into a controlled error without residue."""
from http.client import IncompleteRead
def interrupted(*args, **kwargs):
"""Simulate an incomplete HTTP response before an artifact can be published."""
raise IncompleteRead(b"partial", len(artifact))
monkeypatch.setattr(store, "urlopen", interrupted)
with pytest.raises(store.ModelError, match="Could not download"):
store.ensure_model(tmp_path)
assert not (tmp_path / store.MODEL_NAME).exists()
assert not list(tmp_path.glob("*.part"))
+119
View File
@@ -0,0 +1,119 @@
"""Observable crop/pad/composite behavior, with no model download."""
import numpy as np
import pytest
from lama_inpaint.processing import InpaintProcessor
def paint_red(image, mask):
"""Validate padded geometry and return an unmistakable RGB prediction."""
assert image.shape[:2] == mask.shape
assert image.shape[0] % 8 == image.shape[1] % 8 == 0
return np.full_like(image, (255, 0, 0))
@pytest.mark.parametrize("shape", [(1, 1), (7, 9), (16, 16), (801, 19), (17, 803)])
def test_masked_pixels_change_and_originals_survive(shape):
"""Check BGR output, edge masks, exact unmasked pixels and immutable source arrays."""
image = np.full((*shape, 3), (13, 47, 91), dtype=np.uint8)
mask = np.zeros(shape, dtype=np.uint8)
mask[0, 0] = 255
mask[-1, -1] = 255
before = image.copy(), mask.copy()
out = InpaintProcessor(paint_red)(image, mask)
assert out.shape == image.shape and out.dtype == np.uint8
np.testing.assert_array_equal(out[mask == 0], image[:, :, ::-1][mask == 0])
assert np.all(out[mask == 255] == (0, 0, 255))
np.testing.assert_array_equal(image, before[0])
np.testing.assert_array_equal(mask, before[1])
assert not np.shares_memory(out, image)
def test_empty_mask_skips_model_and_returns_independent_bgr():
"""Empty masks must skip prediction and return an independent channel-swapped copy."""
image = np.full((801, 4, 3), (11, 29, 83), dtype=np.uint8)
def fail(*args):
"""Fail immediately if an empty-mask request reaches the predictor."""
pytest.fail("empty mask must not invoke the model")
out = InpaintProcessor(fail)(image, np.zeros((801, 4), np.uint8))
np.testing.assert_array_equal(out, image[:, :, ::-1])
assert not np.shares_memory(out, image)
@pytest.mark.parametrize("shape", [(3, 5), (801, 3)])
def test_full_mask_and_soft_mask(shape):
"""Check full replacement and proportional blending above and below the crop trigger."""
image = np.full((*shape, 3), (11, 29, 83), dtype=np.uint8)
mask = np.full(shape, 64, dtype=np.uint8)
out = InpaintProcessor(paint_red)(image, mask)
expected = (
np.array((0, 0, 255)) * (64 / 255) + np.array((83, 29, 11)) * (1 - 64 / 255)
).astype(np.uint8)
assert np.all(out == expected)
assert np.all(
InpaintProcessor(paint_red)(image, np.full(shape, 255, np.uint8)) == (0, 0, 255)
)
def test_overlapping_crops_always_use_original_context():
"""Overlapping context must not feed an earlier prediction into another model call."""
image = np.full((900, 900, 3), 37, np.uint8)
mask = np.zeros((900, 900), np.uint8)
mask[200:205, 200:205] = 255
mask[220:225, 220:225] = 255
calls = []
def predictor(rgb, selected):
"""Inspect the padded source crop and return a controlled prediction."""
assert np.all(rgb == 37)
calls.append(rgb.shape)
return paint_red(rgb, selected)
out = InpaintProcessor(predictor)(image, mask)
assert len(calls) == 2
assert np.all(out[mask == 0] == 37)
assert np.all(out[mask != 0] == (0, 0, 255))
def test_small_image_symmetric_padding():
"""Extend tiny inputs symmetrically without changing their original-size result."""
image = np.arange(18, dtype=np.uint8).reshape(2, 3, 3)
seen = []
def predictor(rgb, mask):
"""Inspect the padded source crop and return a controlled prediction."""
seen.append(rgb.copy())
return rgb
out = InpaintProcessor(predictor)(image, np.full((2, 3), 255, np.uint8))
np.testing.assert_array_equal(out, image[:, :, ::-1])
assert seen[0].shape == (32, 32, 3)
np.testing.assert_array_equal(seen[0][0, 3], image[0, 2])
np.testing.assert_array_equal(seen[0][2, 0], image[1, 0])
@pytest.mark.parametrize(
"image,mask",
[
(np.zeros((0, 2, 3), np.uint8), np.zeros((0, 2), np.uint8)),
(np.zeros((2, 2, 4), np.uint8), np.zeros((2, 2), np.uint8)),
(np.zeros((2, 2, 3), np.float32), np.zeros((2, 2), np.uint8)),
(np.zeros((2, 2, 3), np.uint8), np.zeros((2, 3), np.uint8)),
(np.zeros((2, 2, 3), np.uint8), np.zeros((2, 2), bool)),
],
)
def test_reject_invalid_inputs(image, mask):
"""Reject invalid dimensions, channels, dtypes and mismatched masks before inference."""
with pytest.raises(ValueError):
InpaintProcessor(paint_red)(image, mask)
def test_reject_invalid_model_output():
"""Reject a predictor result that violates the padded uint8 RGB contract."""
with pytest.raises(ValueError, match="predictor"):
InpaintProcessor(lambda *_: np.zeros((8, 8, 3)))(
np.zeros((2, 2, 3), np.uint8), np.full((2, 2), 255, np.uint8)
)
+69
View File
@@ -0,0 +1,69 @@
"""Real TorchScript execution on a generated fixture; no downloaded models."""
import hashlib
import numpy as np
import pytest
torch = pytest.importorskip("torch", reason="requires the application runtime test job")
from lama_inpaint import model_store
from lama_inpaint.runtime import LamaInpaint
@pytest.fixture
def scripted_weights(tmp_path, monkeypatch):
"""Create and checksum a tiny local TorchScript fixture without model downloads."""
class Paint(torch.nn.Module):
def forward(self, image, mask):
# Exercise both tensor inputs; model writes red into selected pixels.
"""Use both tensor inputs to replace selected pixels with RGB red."""
color = torch.zeros_like(image)
color[:, 0] = 1
return image * (1 - mask) + color * mask
traced = torch.jit.trace(
Paint(), (torch.zeros(1, 3, 8, 8), torch.zeros(1, 1, 8, 8, dtype=torch.int64))
)
path = tmp_path / model_store.MODEL_NAME
torch.jit.save(traced, path)
data = path.read_bytes()
monkeypatch.setattr(model_store, "MODEL_SIZE", len(data))
monkeypatch.setattr(model_store, "MODEL_SHA256", hashlib.sha256(data).hexdigest())
return tmp_path
def test_cpu_scripted_model_end_to_end(scripted_weights):
"""Exercise verified loading, tensor conversion and compositing with a real TorchScript call."""
model = LamaInpaint("cpu", cache_dir=scripted_weights, download=False)
image = np.full((13, 17, 3), (23, 51, 81), np.uint8)
mask = np.zeros((13, 17), np.uint8)
mask[2:7, 3:9] = 255
result = model(image, mask)
assert np.all(result[mask == 255] == (0, 0, 255))
np.testing.assert_array_equal(result[mask == 0], image[:, :, ::-1][mask == 0])
def test_corrupt_weights_are_never_deserialized(scripted_weights, monkeypatch):
"""Reject modified artifact bytes before invoking the TorchScript loader."""
(scripted_weights / model_store.MODEL_NAME).write_bytes(b"corrupt")
monkeypatch.setattr(
torch.jit,
"load",
lambda *a, **k: pytest.fail("must not load unverified weights"),
)
with pytest.raises(model_store.ModelError):
LamaInpaint("cpu", cache_dir=scripted_weights, download=False)
def test_reject_unvalidated_device_before_loading():
"""Reject MPS explicitly rather than promising unvalidated operator support."""
with pytest.raises(ValueError, match="MPS"):
LamaInpaint("mps", download=False)
def test_cuda_unavailable_is_explicit(monkeypatch):
"""Report unavailable CUDA without silently selecting a different device."""
monkeypatch.setattr(torch.cuda, "is_available", lambda: False)
with pytest.raises(RuntimeError, match="CUDA"):
LamaInpaint("cuda", download=False)
+97
View File
@@ -0,0 +1,97 @@
"""Execute Linux setup with controlled command stubs; never install or download."""
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
import pytest
pytestmark = pytest.mark.skipif(
sys.platform != "linux", reason="Linux installer branches"
)
REPO = Path(__file__).resolve().parents[1]
def run_setup(tmp_path, *, gpu, mirror, fail_install=False):
"""Record selected indexes and stage ordering from the actual shell script."""
project = tmp_path / "project with spaces"
project.mkdir()
shutil.copyfile(REPO / "setup.sh", project / "setup.sh")
bindir = tmp_path / "bin"
bindir.mkdir()
program = tmp_path / "fake_python.py"
program.write_text(
r"""import json, os, sys
from pathlib import Path
args = sys.argv[1:]
with open(os.environ['WMR_TEST_LOG'], 'a') as log:
log.write(json.dumps(args) + '\n')
if args[:1] == ['-c'] and 'sys.version_info' in args[1]:
print('3.12')
elif args[:2] == ['-m', 'venv']:
activate = Path(args[2]) / 'bin' / 'activate'
activate.parent.mkdir(parents=True)
activate.write_text('# controlled environment\n')
elif 'requirements.txt' in args and os.environ.get('WMR_TEST_FAIL') == '1':
sys.exit(17)
""",
encoding="utf-8",
)
wrapper = '#!/bin/sh\nexec "$WMR_TEST_PYTHON" "$WMR_TEST_PROGRAM" "$@"\n'
for name in ("python3.12", "python", "pip"):
executable = bindir / name
executable.write_text(wrapper, encoding="utf-8")
executable.chmod(0o755)
if gpu:
executable = bindir / "nvidia-smi"
executable.write_text("#!/bin/sh\nexit 0\n", encoding="utf-8")
executable.chmod(0o755)
log = tmp_path / "commands.jsonl"
env = os.environ.copy()
env.update(
PATH=f"{bindir}:/usr/bin:/bin",
WMR_TEST_PYTHON=sys.executable,
WMR_TEST_PROGRAM=str(program),
WMR_TEST_LOG=str(log),
WMR_TEST_FAIL="1" if fail_install else "0",
PIP_EXTRA_INDEX_URL="https://packages.invalid/simple",
)
result = subprocess.run(
["/bin/bash", str(project / "setup.sh")],
input=("y" if mirror else "n") + "n",
env=env,
text=True,
capture_output=True,
timeout=30,
)
commands = [json.loads(line) for line in log.read_text().splitlines()]
return result, commands
@pytest.mark.parametrize("gpu", [False, True])
@pytest.mark.parametrize("mirror", [False, True])
def test_torch_backend_index_is_unambiguous(tmp_path, gpu, mirror):
"""Verify CPU/CUDA index selection independently of the general package mirror."""
result, commands = run_setup(tmp_path, gpu=gpu, mirror=mirror)
assert result.returncode == 0, result.stdout + result.stderr
torch_install = next(args for args in commands if "torch>=2.4.0" in args)
expected = "https://download.pytorch.org/whl/" + ("cu124" if gpu else "cpu")
assert torch_install[torch_install.index("--index-url") + 1] == expected
assert "--isolated" in torch_install
assert "--extra-index-url" not in torch_install
assert "-i" not in torch_install
app_install = next(args for args in commands if "requirements.txt" in args)
assert ("-i" in app_install) == mirror
assert ["-m", "pip", "check"] in commands
assert ["-m", "lama_inpaint", "download"] in commands
def test_install_failure_stops_before_model_preparation(tmp_path):
"""A failed dependency install must stop before download or success reporting."""
result, commands = run_setup(tmp_path, gpu=False, mirror=False, fail_install=True)
assert result.returncode == 17
assert ["-m", "lama_inpaint", "download"] not in commands
assert "Setup complete!" not in result.stdout