diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md new file mode 100644 index 0000000..d0c31d6 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -0,0 +1,37 @@ +--- +name: Bug report +about: Report a defect in AlphaForge +title: "[bug] " +labels: bug +--- + +## Describe the bug + +A clear and concise description of what the bug is. + +## To Reproduce + +Steps or code to reproduce the behavior: + +1. ... +2. ... +3. ... + +## Expected behavior + +A clear and concise description of what you expected to happen. + +## Actual behavior + +What actually happened. + +## Environment + +- AlphaForge version: +- Node.js version: +- pnpm version: +- Operating system: + +## Additional context + +Add any other context, logs, or sample images here. diff --git a/.github/ISSUE_TEMPLATE/feature_request.md b/.github/ISSUE_TEMPLATE/feature_request.md new file mode 100644 index 0000000..4593df0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/feature_request.md @@ -0,0 +1,26 @@ +--- +name: Feature request +about: Suggest a new feature or improvement for AlphaForge +title: "[feature] " +labels: enhancement +--- + +## Summary + +A clear and concise description of the feature or improvement. + +## Motivation + +Why is this needed? What problem does it solve? + +## Proposed solution + +Describe how you think this should work. + +## Alternatives + +What alternatives have you considered? + +## Additional context + +Add any other context, references, or examples here. diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000..d5b3803 --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,25 @@ +## Description + + + + +## Changes + +- +- + + +## Tests + +- [ ] pnpm lint +- [ ] pnpm typecheck +- [ ] pnpm test +- [ ] pnpm build + + +## Checklist + +- [ ] Follows architecture +- [ ] Documentation updated if needed +- [ ] TASKS.md updated if needed +- [ ] Conventional commit used diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml new file mode 100644 index 0000000..15c3584 --- /dev/null +++ b/.github/workflows/ci.yml @@ -0,0 +1,42 @@ +name: CI + +on: + push: + branches: [main, develop] + pull_request: + branches: [main, develop] + +jobs: + build: + runs-on: ubuntu-latest + + steps: + - name: Checkout repository + uses: actions/checkout@v4 + + - name: Setup pnpm + uses: pnpm/action-setup@v4 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: 22 + cache: pnpm + + - name: Install dependencies + run: pnpm install --frozen-lockfile + + - name: Check formatting + run: pnpm run format:check + + - name: Lint + run: pnpm run lint + + - name: Type check + run: pnpm run typecheck + + - name: Run tests + run: pnpm run test + + - name: Build + run: pnpm run build diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..8573975 --- /dev/null +++ b/.gitignore @@ -0,0 +1,7 @@ +node_modules/ +dist/ +coverage/ +*.log +.env +.env.local +.DS_Store diff --git a/.prettierignore b/.prettierignore new file mode 100644 index 0000000..c700bc9 --- /dev/null +++ b/.prettierignore @@ -0,0 +1 @@ +.github/pull_request_template.md diff --git a/.prettierrc.json b/.prettierrc.json new file mode 100644 index 0000000..bbc5169 --- /dev/null +++ b/.prettierrc.json @@ -0,0 +1,8 @@ +{ + "semi": true, + "singleQuote": false, + "trailingComma": "all", + "printWidth": 100, + "tabWidth": 2, + "useTabs": false +} diff --git a/ADR.md b/ADR.md new file mode 100644 index 0000000..b21481a --- /dev/null +++ b/ADR.md @@ -0,0 +1,503 @@ +# ADR.md + +# Architecture Decision Records + +This document contains lightweight records of accepted architectural decisions for AlphaForge. + +Only accepted decisions are recorded here. Proposals and discussions should happen in issues or pull requests before an ADR is added. + +## Index + +| Number | Title | Status | +| ------- | -------------------------------------------------------------------- | -------- | +| ADR-001 | Use TypeScript with strict mode | Accepted | +| ADR-002 | Use pnpm as package manager | Accepted | +| ADR-003 | Use Vitest as test runner | Accepted | +| ADR-004 | Use ESLint for static analysis | Accepted | +| ADR-005 | Use Sharp as the image loading backend | Accepted | +| ADR-006 | Use RGBA8 as the canonical internal image representation | Accepted | +| ADR-007 | Use Conventional Commits | Accepted | +| ADR-008 | Use a main/develop/feature branch workflow with squash merge | Accepted | +| ADR-009 | Use Float32Array and LinearImageData for linear RGB color processing | Accepted | +| ADR-010 | Verification Strategy Using Property-Based Testing | Accepted | +| ADR-011 | PNG Export Strategy | Accepted | +| ADR-012 | Background Validation Before Inference | Accepted | +| ADR-013 | Core Freeze | Accepted | +| ADR-014 | Public API Freeze | Accepted | + +## ADR-001: Use TypeScript with strict mode + +- **Status:** Accepted +- **Context:** AlphaForge needs a typed, maintainable language for production-grade image processing. Type safety is essential for correctness and refactoring confidence. +- **Decision:** Use TypeScript with `strict` mode enabled and additional conservative compiler flags. +- **Rationale:** Strict typing catches errors early, improves code clarity, and aligns with the project's determinism and correctness goals. + +## ADR-002: Use pnpm as package manager + +- **Status:** Accepted +- **Context:** A package manager must install dependencies deterministically and efficiently. +- **Decision:** Use pnpm for dependency management. +- **Rationale:** pnpm provides fast, deterministic installs, strict dependency resolution, and disk-efficient storage. + +## ADR-003: Use Vitest as test runner + +- **Status:** Accepted +- **Context:** The project needs a modern test runner that supports TypeScript and ESM natively. +- **Decision:** Use Vitest for unit, integration, and regression tests. +- **Rationale:** Vitest supports TypeScript and ESM without extra transpilation, offers fast watch mode, and integrates well with the existing toolchain. + +## ADR-004: Use ESLint for static analysis + +- **Status:** Accepted +- **Context:** Consistent code style and static analysis are needed to keep the codebase maintainable. +- **Decision:** Use ESLint with TypeScript-aware rules. +- **Rationale:** ESLint is the standard TypeScript linter, configurable, and can be enforced in CI. + +## ADR-005: Use Sharp as the image loading backend + +- **Status:** Accepted +- **Context:** AlphaForge needs robust image decoding and encoding without implementing its own codecs. +- **Decision:** Use the Sharp library for image loading. +- **Rationale:** Sharp is mature, widely used, supports common formats, and provides raw RGBA data efficiently. + +## ADR-006: Use RGBA8 as the canonical internal image representation + +- **Status:** Accepted +- **Context:** All processing algorithms need a single, predictable pixel format to keep math simple and deterministic. +- **Decision:** Represent every loaded image as RGBA8 (four 8-bit channels) through the `ImageData` interface. +- **Rationale:** A unified pixel format simplifies algorithm implementation, reduces conversion bugs, and is sufficient for production assets. Other formats are converted at the IO boundary. + +## ADR-007: Use Conventional Commits + +- **Status:** Accepted +- **Context:** A consistent commit format makes history readable and enables automated tooling. +- **Decision:** All commits must follow the Conventional Commits specification. +- **Rationale:** Conventional Commits are machine-readable, support release automation, and improve code review. + +## ADR-008: Use a main/develop/feature branch workflow with squash merge + +- **Status:** Accepted +- **Context:** A lightweight branching model is needed to keep `main` stable while allowing parallel feature work. +- **Decision:** + - `main` is the production-ready branch. + - `develop` is the integration branch. + - Work happens in `feature/*` and `fix/*` branches. + - Branches are merged into `develop` using squash merge. + - Releases are prepared by merging `develop` into `main`. +- **Rationale:** Squash merge keeps history clean, short-lived branches isolate risk, and periodic merges to `main` create clear release boundaries. + +## ADR-009: Use Float32Array and LinearImageData for linear RGB color processing + +- **Status:** Accepted +- **Context:** Alpha reconstruction and other future processing stages need to operate in linear RGB color space to preserve mathematical correctness. The IO layer uses RGBA8 (`Uint8Array`) for storage efficiency, but linear RGB values require higher precision and a different data layout. +- **Decision:** + - Use `Float32Array` to store linear RGB color values. + - Introduce a dedicated `LinearImageData` type with format `linear-rgba8`. + - All color science and reconstruction algorithms operate on `LinearImageData`. + - sRGB only exists at the library input and output boundaries. +- **Rationale:** Floating-point storage preserves the precision needed for gamma-encoded values and future compositing math. A separate type prevents confusion between sRGB and linear data and makes the pipeline's color space contract explicit. + +## ADR-010: Verification Strategy Using Property-Based Testing + +- **Status:** Accepted +- **Context:** AlphaForge processes mathematical transformations over large input spaces, including floating-point color values, alpha channels, image dimensions, and reconstruction parameters. Traditional example-based testing can verify known inputs and expected outputs, but it cannot exhaustively prove that algorithmic invariants hold across the entire valid input space. +- **Decision:** AlphaForge adopts property-based testing as a complementary verification strategy. It is required alongside unit and integration testing for production-critical algorithms. + + Property-based testing must be considered for: + + - mathematical transformations + - color conversions + - alpha reconstruction + - foreground reconstruction + - cleanup operations + - image processing pipelines + - deterministic transformations + +- **Testing Responsibilities:** + + Unit tests verify: + + - known examples + - expected outputs + - error handling + - regression cases + + Property tests verify: + + - universal invariants + - edge cases + - mathematical guarantees + +- **Required Properties:** Common properties to verify include: + + - deterministic output for identical inputs and configuration + - input immutability + - valid output ranges + - no `NaN` or `Infinity` values + - dimension preservation + - idempotence when mathematically expected + +- **Implementation:** The current test framework is Vitest, and property-based tests are implemented using a property-testing library such as fast-check. + +- **Constraints:** Property tests do not replace unit tests. Both strategies are required for production-critical algorithms. Property tests should focus on invariants, not specific examples. + +- **Consequences:** + + Benefits: + + - stronger correctness guarantees + - safer refactoring + - better edge case detection + + Costs: + + - additional test complexity + - longer execution time + +- **Rationale:** Property-based testing aligns with AlphaForge's commitment to determinism, mathematical correctness, and production reliability. It provides a systematic way to verify that invariants hold across generated inputs, complementing the targeted coverage of unit and integration tests. + +## ADR-011: PNG Export Strategy + +- **Status:** Accepted +- **Context:** AlphaForge currently reconstructs production-ready assets internally + using `ForegroundImageData` and `AlphaChannelData`. The final pipeline stage must + convert these mathematical representations into standard PNG assets. + + PNG export introduces architectural concerns: + + - where encoding responsibility belongs + - color conversion boundaries between linear RGB and sRGB + - dependency usage + - deterministic processing guarantees + +- **Decision:** + + - PNG export will be implemented as a dedicated export module. + - The new module location will be `src/export/`. + - Sharp will be reused as the PNG encoder. + - Image loading and image exporting remain separate responsibilities. + - Export must not duplicate color science logic. + - The existing `linearToSrgb` conversion must be reused. + - Export produces standard RGBA8 PNG files. + - The initial public API will be path-based. + - Export operations are asynchronous because Sharp is asynchronous. + + **Architectural Responsibilities:** + + - `src/io/` is responsible for reading external images, decoding image formats, + and producing `ImageData`. + - `src/color/` is responsible for sRGB ↔ Linear RGB conversion and color + correctness. + - `src/reconstruction/` is responsible for mathematical alpha recovery and + foreground recovery. + - `src/cleanup/` is responsible for deterministic post-processing. + - `src/export/` is responsible for converting final reconstructed data into + PNG output and encoding PNG files. + + **Public API Decision:** + + The initial public API contract is: + + ```ts + exportPng(options: ExportPngOptions): Promise + ``` + + with the supporting types: + + ```ts + interface ExportPngOptions { + readonly foreground: ForegroundImageData; + readonly alpha: AlphaChannelData; + readonly path: string; + } + + interface ExportResult { + readonly path: string; + readonly bytes: number; + } + ``` + +- **Rejected Alternatives:** + + - **Buffer-only export:** Rejected because the current primary use case is + production asset generation, CLI compatibility is more important initially, + and additional APIs can be introduced later if justified. + - **Export combined with cleanup:** Rejected because cleanup and export have + different responsibilities and combining them would violate module + separation. + - **Custom PNG encoder:** Rejected because it adds unnecessary complexity, + duplicates existing dependency capabilities, and Sharp already exists as the + image backend. + +- **Consequences:** + + **Positive:** + + - small stable API + - minimal additional dependencies + - CLI friendly + - consistent with current architecture + - preserves separation of concerns + + **Negative:** + + - Sharp remains part of the implementation details + - future browser or WASM support may require another encoding layer + +- **Rationale:** A dedicated export module keeps the conversion boundary clear, + reuses the proven color conversion logic, and lets the public API focus on the + single task of producing a PNG file from reconstructed data. + +## ADR-012: Background Validation Before Inference + +- **Status:** Accepted +- **Context:** AlphaForge reconstruction assumes that the two background colors + `B1` and `B2` provided by the caller are exactly the colors used to render the + observations. Real-world validation has shown that AI-generated images often use + backgrounds that differ from the requested color values (for example, a + requested black background may be rendered as `[11,11,11]`). Feeding incorrect + colors into the Porter-Duff reconstruction equations produces alpha errors and + gray contamination in the output. + + A possible response is to add an optional background inference module that + automatically replaces the caller's values with measured values. Before adding + such a module, AlphaForge needs a deterministic way to warn the caller that the + declared and measured colors disagree. Without this layer, inference would be + silently changing the mathematical assumptions of the pipeline, hiding the + root cause from the user and making debugging harder. + +- **Decision:** + + - Add a dedicated background validation step in `src/validation/` that checks + whether the caller-provided background colors are consistent with the colors + measured on the image borders. + - The comparison must be performed in linear RGB space, because the + reconstruction mathematics operate in linear RGB and sRGB distance does not + reflect the physical error introduced into the equations. + - Validation is optional and explicit. It is not enabled by default. Callers + opt in to the validation and choose whether the pipeline rejects a mismatch. + - When validation is enabled and the mismatch exceeds a deterministic + threshold, the pipeline throws a typed `BackgroundMismatchError` and does + not continue with mathematically invalid assumptions. + - The validation layer reports the mismatch and explains that AI-generated + backgrounds may differ from the requested colors. It does not infer or + replace the caller's values. + - Background inference and replacement belongs to a future milestone + (Milestone 8.6) and will not be implemented here. + +- **Rejected Alternatives:** + + - **Silent inference without validation:** Rejected because it would hide the + root cause from the caller, violate the principle of explicit behavior, and + make the pipeline harder to debug and test. + - **sRGB RGB distance comparison:** Rejected because raw sRGB distance does not + correspond to the error introduced into the linear RGB reconstruction + equations. The comparison must be performed in the same color space as the + mathematics. + - **Default validation on:** Rejected to preserve backward compatibility and to + keep the validation feature explicit. The caller must request background + validation. + - **Warning mode that continues silently:** Rejected for the default behavior. + A future warning mode may be added, but the initial default behavior is to + reject the mismatch so that invalid assumptions are not silently propagated. + +- **Consequences:** + + **Positive:** + + - Users receive a clear, typed error when their declared colors disagree with + the image content. + - The reconstruction core remains unchanged and mathematically correct. + - The validation layer creates a clean foundation for future optional + background inference. + - The comparison is deterministic and testable. + + **Negative:** + + - Callers must opt in to background validation and supply the correct colors. + - The threshold is an additional configurable parameter that must be + documented and tested. + +- **Rationale:** Validation is a safer first step than inference. It preserves the + mathematical correctness of the pipeline, gives users actionable feedback, and + keeps the architecture modular. A future inference module can reuse the same + measurement logic and decide whether replacement is appropriate. + +## ADR-013: Core Freeze + +- **Status:** Accepted +- **Context:** The AlphaForge reconstruction engine is built on the Porter-Duff + two-observation compositing model. The core pipeline — input validation, color + space conversion, alpha reconstruction, foreground reconstruction, optional + cleanup, and PNG export — has been implemented, tested, and reviewed. The + mathematical foundations are closed-form, deterministic, and independent of + machine-learning inference. + + Before declaring the Core API stable, the project needs a clear boundary + between the frozen mathematical core and future optional improvements. Without + that boundary, visual refinements or convenience features could accidentally + alter the deterministic reconstruction model, breaking reproducibility and + long-term API stability. + +- **Decision:** + + - The mathematical reconstruction engine is frozen as the stable foundation of + AlphaForge. + - The core engine includes the Porter-Duff alpha reconstruction, symmetric + foreground reconstruction, the linear RGB color conversion boundary, and the + deterministic input validation that guards the model assumptions. + - Future visual quality improvements, background inference, and edge-aware + refinements must be implemented outside the reconstruction engine. + - A future optional Refinement subsystem may be introduced as a composable + layer that operates on the output of the core engine, not inside it. + - The core engine will only change to fix demonstrable mathematical defects; + changes for visual preference or convenience are not permitted. + +- **Rejected Alternatives:** + + - **Keeping the core open for continuous iteration:** Rejected because it would + prevent downstream users from relying on deterministic output and would make + regression testing impossible. + - **Embedding visual refinements inside the reconstruction engine:** Rejected + because it would couple subjective improvements to the mathematical model and + risk altering reproducible output. + - **Freezing only the public function signatures while allowing internal + algorithm changes:** Rejected because AlphaForge's value is the deterministic + mathematical behavior, not just the shape of the API. + +- **Consequences:** + + **Positive:** + + - The reconstruction engine becomes a trustworthy, long-term foundation. + - Users can rely on deterministic output for a fixed Core API version. + - Visual improvements can evolve without destabilizing production pipelines. + - The separation of concerns improves maintainability and testability. + + **Negative:** + + - Future changes to the core require stronger justification. + - New capabilities that need core modifications must go through a deliberate + amendment process rather than routine feature work. + +- **Rationale:** Freezing the core protects AlphaForge's most important property: + deterministic, mathematically correct reconstruction. Optional improvements can + be added around the core, but the core itself must remain stable so that + AlphaForge can become a reliable reference implementation for deterministic + post-processing of AI-generated assets. + +## ADR-014: Public API Freeze + +- **Status:** Accepted +- **Context:** AlphaForge's public API has reached a stable state after completing + the image loading, validation, color science, alpha reconstruction, foreground + reconstruction, cleanup, PNG export, and pipeline orchestration milestones. + `ADR-013: Core Freeze` froze the mathematical reconstruction engine; this ADR + records the companion decision to freeze the public API surface that exposes + the core to consumers. + + The public API is the boundary through which downstream users depend on + AlphaForge. Without a frozen public API, the deterministic guarantees of the + core cannot be relied upon across releases. A clear stability contract is + required so that production pipelines can safely integrate AlphaForge and so + that future contributors understand what may and may not change. + +- **Decision:** + + - The public API boundary is frozen as declared in `API.md`. + - Only exports listed in `API.md` and re-exported from the package root + (`src/index.ts`) are **Public**. + - Public function signatures, return types, and the error contract are covered + by the stability contract. + - The frozen public API includes the functions, types, options, and error + classes documented in `API.md`. + - Internal helpers, module file paths, directory structure, and error message + strings remain **Internal** and may change without notice. + - Future visual improvements, background inference, edge-aware refinements, + and other optional enhancements must be implemented as composable layers + outside the frozen core API. + +- **Semantic Versioning Expectations:** + + - Until `v1.0.0`, the public API is frozen as part of the Core Freeze + milestone. Necessary corrections to demonstrable defects may still occur + under the `v0.x` line. + - After `v1.0.0`, the project follows Semantic Versioning: + - Patch releases may fix bugs without changing public API behavior. + - Minor releases may add compatible public API surface area. + - Major releases are required for breaking changes to Public APIs. + +- **Compatible Changes:** + + - Adding new public functions, types, or options. + - Adding new optional fields to existing options objects. + - Broadening accepted input ranges where previously undefined. + - Adding new error subclasses that inherit from existing public errors. + - Adding new optional modules that depend on the frozen core. + +- **Breaking Changes:** + + - Removing or renaming public functions, types, or options. + - Changing the type, cardinality, or required status of existing public + function parameters. + - Changing the structure, semantics, or valid ranges of public return types. + - Changing the public error contract beyond adding documented optional + properties. + - Altering the deterministic output of existing public functions. + +- **Future Extension Strategy:** + + - Optional refinements, such as background inference or edge-aware filters, + will be introduced as new public functions or modules that operate on core + outputs. + - Refinement layers must remain opt-in and must not alter the deterministic + output of existing core functions. + - New modules may depend on the frozen core but may not modify it. + - A future **Refinement** subsystem may consume deterministic core output and + apply optional visual improvements. It must be layered above the core, not + embedded inside it. + +- **Relationship to ADR-013:** + + - `ADR-013` freezes the mathematical reconstruction engine. + - `ADR-014` freezes the public API that exposes the engine. + - Together they establish that both the deterministic behavior and the + contract through which consumers access it are stable. + +- **Rejected Alternatives:** + + - **Declaring the core frozen without a public API contract:** Rejected + because external consumers need a clear, documented boundary to rely on. + - **Freezing only function signatures while allowing return type changes:** + Rejected because the stability contract must cover signatures, return types, + and error behavior. + - **Allowing refinements inside the core API:** Rejected because it would + couple optional visual improvements to the stable contract and risk breaking + changes for production pipelines. + - **Committing to zero changes before v1.0.0:** Rejected because the project + must retain the ability to fix demonstrable mathematical defects during the + pre-1.0 stabilization window. + +- **Consequences:** + + **Positive:** + + - Consumers can depend on the public API for production pipelines. + - Refactoring and extension can proceed without destabilizing existing users. + - The separation between core and optional layers is explicit and + enforceable. + - Release versioning becomes predictable and communicable. + + **Negative:** + + - Future improvements that require public API changes must go through a + deliberate design and versioning process. + - The public surface must be maintained carefully across releases. + - Pre-1.0 corrections must be clearly justified as defect fixes rather than + feature work. + +- **Rationale:** Freezing the public API completes the Core Freeze by giving + downstream users a stable contract. It protects AlphaForge's determinism and + correctness guarantees at the boundary where consumers interact with the + library, while still allowing the project to grow through optional, composable + extension layers. diff --git a/API.md b/API.md new file mode 100644 index 0000000..2b40193 --- /dev/null +++ b/API.md @@ -0,0 +1,144 @@ +# API.md + +# AlphaForge Public API + +This document is the canonical registry of the AlphaForge public API. + +The public API boundary is the package root entry point (`src/index.ts` / `dist/index.js`). Any export that is not re-exported from the package root is considered **Internal** and may change without notice. + +Only APIs documented in this file are **Public**. Public APIs are covered by the stability contract; Internal APIs are not. + +## Status Legend + +| Status | Meaning | +| ---------- | --------------------------------------------------------------------- | +| Public | Part of the supported public API. Changes follow semantic versioning. | +| Internal | Not part of the public API. May change or be removed without notice. | +| Deprecated | Public API that is planned for removal. | + +## Public API + +### IO + +| Name | Status | Source file | Re-export location | Description | +| ------------------ | ------ | ------------------------ | ---------------------------------- | ----------------------------------------------------------------------- | +| `VERSION` | Public | `src/index.ts` | `src/index.ts` | Library version string. | +| `loadImage` | Public | `src/io/image-loader.ts` | `src/io/index.ts` → `src/index.ts` | Load and decode an image file into the AlphaForge RGBA8 representation. | +| `ImageData` | Public | `src/io/image-types.ts` | `src/io/index.ts` → `src/index.ts` | Immutable decoded RGBA8 image container. | +| `LoadImageOptions` | Public | `src/io/image-types.ts` | `src/io/index.ts` → `src/index.ts` | Options for `loadImage`. | +| `ImageLoadError` | Public | `src/io/image-types.ts` | `src/io/index.ts` → `src/index.ts` | Error thrown when image loading or decoding fails. | + +### Validation + +| Name | Status | Source file | Re-export location | Description | +| --------------------------------------- | ------ | ----------------------------------------- | ------------------------------------------ | ------------------------------------------------------------------------ | +| `validateImages` | Public | `src/validation/validate-images.ts` | `src/validation/index.ts` → `src/index.ts` | Validate a pair of images for metadata and dimension compatibility. | +| `assertImagesValid` | Public | `src/validation/validate-images.ts` | `src/validation/index.ts` → `src/index.ts` | Assert that a pair of images is valid, throwing typed errors on failure. | +| `validateImageMetadata` | Public | `src/validation/metadata-validator.ts` | `src/validation/index.ts` → `src/index.ts` | Validate the metadata of a single image. | +| `validateImageDimensions` | Public | `src/validation/dimension-validator.ts` | `src/validation/index.ts` → `src/index.ts` | Validate that two images have matching dimensions. | +| `ValidationError` | Public | `src/validation/validation-errors.ts` | `src/validation/index.ts` → `src/index.ts` | Base error for all validation failures. | +| `MetadataValidationError` | Public | `src/validation/validation-errors.ts` | `src/validation/index.ts` → `src/index.ts` | Error thrown when image metadata is invalid. | +| `DimensionValidationError` | Public | `src/validation/validation-errors.ts` | `src/validation/index.ts` → `src/index.ts` | Error thrown when image dimensions are incompatible. | +| `ValidationIssue` | Public | `src/validation/validation-types.ts` | `src/validation/index.ts` → `src/index.ts` | A single validation issue discovered by a validator. | +| `ValidationResult` | Public | `src/validation/validation-types.ts` | `src/validation/index.ts` → `src/index.ts` | Structured result of validating an image pair. | +| `measureBackgroundColor` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Measure the mean linear RGB color of an image border. | +| `validateBackgroundColors` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Compare declared background colors against measured border colors. | +| `assertBackgroundColorsValid` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Assert that declared background colors match measured border colors. | +| `BackgroundMismatchError` | Public | `src/validation/background-errors.ts` | `src/validation/index.ts` → `src/index.ts` | Error thrown when a background mismatch is detected. | +| `DEFAULT_BACKGROUND_BORDER_WIDTH` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Default border width for background sampling. | +| `DEFAULT_BACKGROUND_MISMATCH_THRESHOLD` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Default linear RGB mismatch threshold. | +| `MeasuredBackgroundColor` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Metadata from a measured border color. | +| `BackgroundValidationOptions` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Options for background validation. | +| `BackgroundValidationResult` | Public | `src/validation/background-validation.ts` | `src/validation/index.ts` → `src/index.ts` | Result of comparing declared and measured background colors. | +| `BackgroundMismatchContext` | Public | `src/validation/background-errors.ts` | `src/validation/index.ts` → `src/index.ts` | Context attached to a BackgroundMismatchError. | + +### Color + +| Name | Status | Source file | Re-export location | Description | +| ----------------- | ------ | ------------------------------ | ------------------------------------- | ---------------------------------------------------------- | +| `srgbToLinear` | Public | `src/color/color-converter.ts` | `src/color/index.ts` → `src/index.ts` | Convert an RGBA8 image to linear RGB color space. | +| `linearToSrgb` | Public | `src/color/color-converter.ts` | `src/color/index.ts` → `src/index.ts` | Convert a linear RGB image back to RGBA8 sRGB color space. | +| `LinearImageData` | Public | `src/color/color-types.ts` | `src/color/index.ts` → `src/index.ts` | Immutable decoded image data in linear RGB color space. | + +Both color conversion functions throw `Error` when the input format or buffer dimensions are invalid. The exact error subclass is an internal implementation detail. + +### Reconstruction + +| Name | Status | Source file | Re-export location | Description | +| ------------------------------- | ------ | ------------------------------------------------- | ---------------------------------------------- | ------------------------------------------------------------------------------------------------------------- | +| `reconstructAlpha` | Public | `src/reconstruction/alpha-reconstruction.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Reconstruct a scalar alpha channel from two linear RGB observations. | +| `AlphaReconstructionError` | Public | `src/reconstruction/alpha-errors.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Error thrown when alpha reconstruction fails. | +| `ReconstructionInput` | Public | `src/reconstruction/reconstruction-types.ts` | `src/reconstruction/index.ts` → `src/index.ts` | A linear observation and its known background. | +| `ReconstructAlphaOptions` | Public | `src/reconstruction/alpha-types.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Options for `reconstructAlpha`. | +| `AlphaChannelData` | Public | `src/reconstruction/alpha-types.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Single-channel alpha image in `[0, 1]`. | +| `reconstructForeground` | Public | `src/reconstruction/foreground-reconstruction.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Reconstruct the original foreground color from two linear RGB observations and a reconstructed alpha channel. | +| `ForegroundReconstructionError` | Public | `src/reconstruction/foreground-errors.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Error thrown when foreground reconstruction fails. | +| `ReconstructForegroundOptions` | Public | `src/reconstruction/foreground-types.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Options for `reconstructForeground`. | +| `ForegroundImageData` | Public | `src/reconstruction/foreground-types.ts` | `src/reconstruction/index.ts` → `src/index.ts` | Three-channel linear RGB foreground image in `[0, 1]`. | + +### Cleanup + +| Name | Status | Source file | Re-export location | Description | +| --------------------------- | ------ | ------------------------------- | --------------------------------------- | ----------------------------------------------------------------- | +| `cleanup` | Public | `src/cleanup/cleanup.ts` | `src/cleanup/index.ts` → `src/index.ts` | Deterministic cleanup pipeline for alpha and optional foreground. | +| `CleanupError` | Public | `src/cleanup/cleanup-errors.ts` | `src/cleanup/index.ts` → `src/index.ts` | Error thrown when cleanup fails or receives invalid input. | +| `CleanupOptions` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Options for `cleanup`. | +| `CleanupResult` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Result of `cleanup`. | +| `AlphaThresholdOptions` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Configuration for alpha thresholding. | +| `NoiseRemovalOptions` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Configuration for noise removal. | +| `MorphologyOptions` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Configuration for morphological cleanup. | +| `StructuringElementOptions` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Configuration for a structuring element. | +| `StructuringElementShape` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Structuring element shape: `square`, `disk`, or `cross`. | +| `Connectivity` | Public | `src/cleanup/cleanup-types.ts` | `src/cleanup/index.ts` → `src/index.ts` | Connectivity for noise removal: `4` or `8`. | + +### Export + +| Name | Status | Source file | Re-export location | Description | +| ------------------ | ------ | ----------------------------- | -------------------------------------- | ---------------------------------------------- | +| `exportPng` | Public | `src/export/export-png.ts` | `src/export/index.ts` → `src/index.ts` | Export reconstructed image data to a PNG file. | +| `ExportPngOptions` | Public | `src/export/export-types.ts` | `src/export/index.ts` → `src/index.ts` | Options for `exportPng`. | +| `ExportResult` | Public | `src/export/export-types.ts` | `src/export/index.ts` → `src/index.ts` | Result of a PNG export. | +| `ExportError` | Public | `src/export/export-errors.ts` | `src/export/index.ts` → `src/index.ts` | Error thrown when PNG export fails. | + +### Pipeline + +| Name | Status | Source file | Re-export location | Description | +| ----------------------------------- | ------ | -------------------------------------- | ---------------------------------------- | -------------------------------------------------------------- | +| `reconstructPipeline` | Public | `src/pipeline/reconstruct-pipeline.ts` | `src/pipeline/index.ts` → `src/index.ts` | Orchestrate the full reconstruction pipeline. | +| `PipelineError` | Public | `src/pipeline/pipeline-errors.ts` | `src/pipeline/index.ts` → `src/index.ts` | Error thrown when the reconstruction pipeline fails. | +| `ReconstructPipelineOptions` | Public | `src/pipeline/pipeline-types.ts` | `src/pipeline/index.ts` → `src/index.ts` | Options for `reconstructPipeline`. | +| `ReconstructPipelineResult` | Public | `src/pipeline/pipeline-types.ts` | `src/pipeline/index.ts` → `src/index.ts` | Result of `reconstructPipeline`. | +| `ReconstructPipelineCleanupOptions` | Public | `src/pipeline/pipeline-types.ts` | `src/pipeline/index.ts` → `src/index.ts` | Configuration for optional cleanup stages inside the pipeline. | + +Anything not listed above is Internal. Internal helpers such as `PixelFormat`, `Severity`, `srgbToLinearChannel`, `linearToSrgbChannel`, and `aggregateAlphaEstimates` are intentionally omitted because they are not re-exported from the package root. + +## Error Contract + +All public error classes extend `Error` and set `name` to the class name. Errors are explicit and must never be silently ignored by the library. + +Every public error may carry a `cause?: unknown` property that preserves the original underlying error or value. Callers can inspect `cause` to produce actionable diagnostics. + +Some errors carry additional structured context: + +| Error | `cause` | Extra properties | +| ------------------------------- | ------- | ------------------------------------- | +| `ImageLoadError` | yes | `context?: { path: string }` | +| `ValidationError` | yes | `issues: readonly ValidationIssue[]` | +| `MetadataValidationError` | yes | inherits `ValidationError` | +| `DimensionValidationError` | yes | inherits `ValidationError` | +| `BackgroundMismatchError` | yes | `context?: BackgroundMismatchContext` | +| `AlphaReconstructionError` | yes | none | +| `ForegroundReconstructionError` | yes | none | +| `CleanupError` | yes | none | +| `ExportError` | yes | `context?: { path: string }` | +| `PipelineError` | yes | none | + +`PipelineError.cause` always contains the error raised by the failing stage, which may be any of the errors above. + +The exact error message strings are not part of the public stability contract. Only the error type, the presence of `cause`, and the documented context properties are guaranteed. + +## Stability Contract + +Only exports listed in this document are Public. Public function signatures, return types, and the error contract are covered by the stability contract. + +Internal helpers, module file paths, and error message strings may change without notice. Consumers should rely exclusively on the Public API. diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md new file mode 100644 index 0000000..c58b070 --- /dev/null +++ b/ARCHITECTURE.md @@ -0,0 +1,667 @@ +# ARCHITECTURE.md + +# AlphaForge Architecture + +> High-level architecture of the AlphaForge platform. + +--- + +# Purpose + +This document defines the architectural organization of AlphaForge. + +Its goals are: + +- keep the project modular +- preserve long-term maintainability +- define subsystem boundaries +- minimize coupling +- maximize testability +- make future extensions predictable + +This document intentionally avoids implementation details. + +Implementation may evolve. + +Architecture should remain stable. + +--- + +# Architectural Philosophy + +AlphaForge follows five fundamental principles: + +- Clean Architecture +- SOLID +- Functional-first design +- Modular composition +- Deterministic processing + +The system is designed as a processing pipeline composed of small independent modules. + +Each module should solve one problem exceptionally well. + +--- + +# High-Level Pipeline + +``` + AI Generated Images + │ + ▼ + Input Validation + │ + ▼ + Image Consistency Check + │ + ▼ + Color Space Conversion + │ + ▼ + Mathematical Reconstruction + │ + ▼ + Foreground Reconstruction + │ + ▼ + Optional Post Processing + │ + ▼ + PNG Export + │ + ▼ + Output +``` + +`Debug & Diagnostics` is a side branch that may consume data from any stage but never modifies production output. + +Each stage must be deterministic. + +No stage should modify responsibilities belonging to another stage. + +--- + +# Layered Architecture + +``` +CLI + │ + ▼ +Public API + │ + ▼ +Pipeline + │ + ├─────────────┐ + ▼ ▼ +Validation Reconstruction + │ │ + ▼ ▼ +Utilities Color Science + │ │ + └──────┬──────┘ + ▼ + Image IO +``` + +Dependencies always point downward. + +Lower layers never depend on upper layers. + +--- + +# Project Structure + +``` +alphaforge/ + +src/ + + api/ + + cli/ + + pipeline/ + + validation/ + + reconstruction/ + + color/ + + cleanup/ + + export/ + + debug/ + + io/ + + utils/ + +tests/ + +docs/ + +examples/ + +benchmarks/ + +.github/ +``` + +Every directory has one responsibility. + +--- + +# Core Modules + +## API + +Responsible for the public TypeScript interface. + +Responsibilities: + +- expose library functions +- validate configuration +- keep backward compatibility + +The current public API boundary is `src/index.ts`. A dedicated `src/api/` module may be introduced in a future milestone if the public surface grows beyond what the package root can cleanly express. + +Must never contain image processing logic. + +--- + +## CLI + +Provides command-line access. + +Responsibilities: + +- parse arguments +- display progress +- report errors +- invoke API + +Must never implement algorithms. + +--- + +## Pipeline + +Coordinates execution. + +Responsibilities: + +- orchestrate modules +- enforce execution order +- propagate configuration +- collect metrics + +The pipeline owns the workflow. + +It does not own algorithms. + +--- + +## Validation + +Ensures inputs are safe to process. + +Responsibilities: + +- metadata validation +- dimension validation +- optional background color mismatch validation +- future alignment and quality checks + +Background validation converts sRGB border samples to linear RGB for comparison, so this module depends on Color Science. + +Validation never modifies images. + +--- + +## Color Science + +Responsible for color correctness. + +Responsibilities: + +- sRGB conversion +- Linear RGB conversion +- gamma correction +- color utilities + +All internal processing assumes RGBA8 as the canonical pixel representation. + +No alpha reconstruction belongs here. + +--- + +## Reconstruction + +The mathematical core. + +Responsibilities: + +- alpha recovery +- foreground recovery +- compositing equations +- numerical stability + +This module should remain independent from image loading. + +--- + +## Cleanup + +Optional deterministic refinements. + +Examples: + +- alpha threshold +- morphology +- denoising +- tiny artifact removal + +Cleanup must never invent information. + +--- + +## Export + +Responsible for output generation. + +Examples: + +- PNG +- metadata +- debug outputs + +No mathematical processing belongs here. + +--- + +## Debug + +Diagnostic tools. + +Examples: + +- difference maps +- alpha visualization +- heat maps +- statistics + +Debugging should never modify production output. + +--- + +## IO + +Responsible only for reading and writing files. + +Supported formats: + +- PNG +- JPEG +- WebP (future) + +IO should never perform mathematical processing. + +--- + +# Dependency Rules + +Dependencies always point downward. Higher layers may depend on lower layers; lower layers never depend on higher layers. + +## Module dependency graph + +``` + ┌────────────┐ + │ CLI │ + └──────┬───────┘ + │ + ┌──────┴───────┐ + │ Public API │ + │ (src/index.ts) │ + └──────┬───────┘ + │ + ┌──────┴───────┐ + │ Pipeline │ + └──────┬───────┘ + │ + ┌───────────────────┼───────────────────┐ + │ │ │ + ▼ ▼ ▼ +┌──────────┐ ┌────────────┐ ┌──────────┐ +│Validation│ │Reconstruction│ │ Export │ +└─────┬─────┘ └─────┬──────┘ └─────┬────┘ + │ │ │ │ + │ │ ┌─────┴─────┐ │ + │ └────►│ Color │◄──────────┘ + │ │ Science │ + │ └─────┬─────┘ + │ ┌─────┴─────┐ + │ │ Cleanup │ + │ └─────┬─────┘ + │ │ + └─────────────────┴─────────────────┘ + │ + ┌──────┴──────┐ + │ Image IO │ + └─────────────┘ +``` + +## Allowed dependencies + +| Layer | Module | May depend on | +| ----- | --------------------------- | ---------------------------------------------------------------- | +| 4 | CLI | Public API | +| 4 | Public API (`src/index.ts`) | Pipeline, Validation, Reconstruction, Cleanup, Export, Color, IO | +| 3 | Pipeline | IO, Validation, Color, Reconstruction, Cleanup | +| 2 | Validation | IO, Color | +| 2 | Reconstruction | Color | +| 2 | Export | Color, Reconstruction | +| 1 | Cleanup | Reconstruction | +| 1 | Color | IO | +| 0 | IO | (none) | + +## Forbidden + +- Export calling Cleanup +- Cleanup calling Validation +- Validation calling Export +- CLI calling algorithms directly +- IO performing mathematical operations +- Circular dependencies + +--- + +# Data Flow + +``` +Image Pair + +↓ + +Validation + +↓ + +Linear RGB + +↓ + +Recover Alpha + +↓ + +Recover Foreground + +↓ + +Cleanup + +↓ + +Encode PNG + +↓ + +Output +``` + +Each stage produces immutable data. + +Whenever practical, transformations should return new objects instead of mutating inputs. + +--- + +# Functional Design + +AlphaForge favors pure functions. + +Preferred: + +``` +output = recoverAlpha(input) +``` + +Avoid: + +``` +image.recoverAlpha() +``` + +Functions should be deterministic. + +Functions should avoid hidden state. + +--- + +# Error Handling + +Errors should be explicit. + +Never silently ignore invalid data. + +Every recoverable error should provide: + +- cause +- context +- suggested solution + +Unexpected errors should never be swallowed. + +--- + +# Configuration + +Configuration should be immutable. + +Example: + +``` +RecoveryOptions + +↓ + +Pipeline + +↓ + +Read-only +``` + +Modules must never mutate configuration objects. + +--- + +# Performance Strategy + +Performance is important. + +Correctness is mandatory. + +Optimization priorities: + +1. Algorithmic improvements +2. Memory efficiency +3. Parallel processing +4. SIMD +5. GPU acceleration + +Micro-optimizations should only occur after profiling. + +--- + +# Testing Strategy + +Every module must have: + +- unit tests +- integration tests +- regression tests + +Critical algorithms require golden image tests. + +Architecture should maximize isolated testing. + +--- + +# Verification Architecture + +Verification is a cross-cutting concern. Every layer must be testable in isolation, and production-critical algorithms must be verified through multiple complementary strategies. + +## Verification layers + +- **Unit tests** verify known examples, expected outputs, and error handling. +- **Integration tests** verify that modules compose correctly through the pipeline. +- **Property-based tests** verify universal invariants across generated inputs. + +## Where property-based testing applies + +Property-based testing is required for: + +- mathematical transformations +- color space conversions +- alpha and foreground reconstruction +- cleanup operations +- deterministic pipelines + +## Common invariants + +Property tests should verify properties such as: + +- deterministic output for identical inputs +- input immutability +- valid numerical ranges +- no `NaN` or `Infinity` values +- dimension preservation +- idempotence where mathematically expected + +## Constraints + +Property tests do not replace unit tests or integration tests. + +Both example-based and property-based verification are required for algorithms that transform mathematical data. + +--- + +# Extensibility + +Future modules should integrate without modifying existing ones. + +Examples: + +``` +Alignment + +Batch + +GPU + +WASM + +Python + +Rust + +Metrics + +Plugins +``` + +A future optional **Refinement** layer may sit above the core engine. It would consume deterministic core output and apply optional visual improvements such as background inference or edge-aware filters. It must not modify the reconstruction engine or alter its deterministic output. + +The architecture should remain open for extension but closed for modification. + +--- + +# Public API Philosophy + +The public API should remain intentionally small. + +Internal complexity should never leak into user code. + +Preferred: + +```ts +await recoverAlpha(options); +``` + +Not: + +```ts +new InternalPipeline().createValidator().createColorConverter().recover().cleanup(); +``` + +Simple API. + +Powerful internals. + +--- + +# Public API Contract + +The public API is defined exclusively by `API.md`. + +Only exports documented in `API.md` are considered Public and covered by the stability contract. Internal modules, functions, and types may evolve without notice. + +Consumers should rely only on the Public API documented in `API.md`. + +--- + +# Architectural Constraints + +AlphaForge must never: + +- depend on machine learning inference +- require internet access +- require cloud services +- require external APIs +- hide processing steps +- produce non-deterministic output + +--- + +# Future Evolution + +As the project grows, new capabilities should appear as independent modules rather than expanding existing ones. + +Preferred: + +``` +reconstruction/ + +cleanup/ + +alignment/ + +validation/ +``` + +Not: + +``` +utils/ + +helpers/ + +misc/ + +common/ +``` + +Generic folders should be avoided. + +--- + +# Architecture Motto + +> Small deterministic modules. +> +> Clear responsibilities. +> +> Predictable evolution. diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..d300f44 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,45 @@ +# Changelog + +All notable changes to AlphaForge are documented in this file. + +This project follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). + +AlphaForge is currently pre-1.0. Until version 1.0.0 is released, the public API may still receive necessary corrections that are part of the Core Stabilization process. The project goal is to reach a stable 1.0.0 Core API as soon as the Core Freeze is complete. + +## [Unreleased] + +## [0.9.0] - 2026-08-03 + +### Summary + +First Core Freeze release. The mathematical reconstruction engine and the public API are frozen. + +### Core Freeze + +- Officially declared the AlphaForge Core frozen. +- Stabilized the deterministic reconstruction engine: image loading, validation, linear RGB color conversion, Porter-Duff alpha reconstruction, symmetric foreground reconstruction, optional cleanup, and PNG export. +- Froze the public API surface documented in `API.md`. +- Added `ADR-014: Public API Freeze`, establishing the public API boundary, semantic versioning expectations, and the future extension strategy through optional refinement layers. +- Future visual improvements, background inference, and edge-aware refinements must be implemented outside the core engine. + +### Governance + +- Began the Core Freeze documentation and governance alignment. +- Added `Article XXI — Immutable Mathematical Core` to `CONSTITUTION.md`, establishing that the mathematical reconstruction engine is the immutable foundation of AlphaForge and that visual improvements must remain optional and external to the core engine. +- Added `ADR-013: Core Freeze` documenting the decision to freeze the Porter-Duff reconstruction engine as the stable foundation of the project. +- Created `CHANGELOG.md` to track project history. + +### Documentation + +- Updated `PROJECT.md` to reflect the Core Frozen state and completed milestones. +- Updated `README.md` to reflect the Core Frozen state and prepared release installation and usage notes. +- Updated `ARCHITECTURE.md` to document the `Validation → Color Science` dependency and to introduce a future optional Refinement extension point. +- Updated `API.md` with an explicit error contract documenting `cause`, `context`, and `issues` properties. +- Reviewed and aligned all five reconstruction pipeline specifications (`alpha-reconstruction.md`, `foreground-reconstruction.md`, `cleanup.md`, `validation.md`, `png-export.md`) with the current implementation. + - Specifications now carry a stable status and version `1.0.0`. + - Obsolete branch references and change log sections were removed. + - Obsolete implementation notes were updated or removed. + +### Changed + +- No functional or API behavior changes were made in this release. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..6dfb807 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,393 @@ +# CLAUDE.md + +# AlphaForge AI Development Guidelines + +This document defines the permanent instructions for AI coding agents working on this repository. + +Before making any change, read and respect: + +- PROJECT.md +- CONSTITUTION.md +- ARCHITECTURE.md +- API.md +- ADR.md +- TASKS.md +- README.md + +These documents are the project's source of truth. They define its purpose, principles, architecture, public API, accepted decisions, priorities, and usage. + +--- + +# Role + +Act as a senior software engineer contributing to an open-source production-grade project. + +Your responsibilities: + +- Understand the existing architecture before coding. +- Preserve project principles. +- Prefer simple and maintainable solutions. +- Avoid unnecessary complexity. +- Produce production-quality code. +- Explain important technical decisions. + +Do not behave as a code generator. + +Behave as a software engineer. + +--- + +# Core Rules + +## Follow the Constitution + +The principles defined in `CONSTITUTION.md` are mandatory. + +Never introduce solutions that violate: + +- determinism +- mathematical correctness +- modularity +- maintainability +- reliability + +--- + +## Respect Architecture + +Before adding new files or modules: + +- Check `ARCHITECTURE.md`. +- Place code in the correct layer. +- Avoid creating generic folders. +- Avoid mixing responsibilities. + +Do not bypass architectural boundaries for convenience. + +--- + +# Development Workflow + +Always work incrementally. + +Do not implement the entire project at once. + +Follow this cycle: + +1. Understand the task. +2. Inspect existing code. +3. Explain the approach. +4. Implement the smallest correct change. +5. Run tests. +6. Verify quality. +7. Commit changes. + +--- + +# Documentation Maintenance + +Documentation must stay consistent with the implementation. + +- If the public API changes, update `API.md` before considering the change complete. +- If an architectural decision is made or changed, update `ADR.md`. +- If the roadmap or priorities change, update `TASKS.md`. + +Never let the implementation drift from the documentation. + +--- + +# Branching Strategy + +The repository uses a lightweight branching strategy. + +Branches: + +``` +main +develop +feature/* +fix/* +``` + +Rules: + +- Never commit directly to `main`. +- Never commit directly to `develop`. +- Every change must happen in a dedicated `feature/*` or `fix/*` branch. +- Branches are squash merged into `develop`. +- Releases are prepared by merging `develop` into `main`. + +Examples: + +``` +feature/image-loader + +feature/color-linearization + +feature/alpha-reconstruction + +feature/png-export +``` + +--- + +# Commit Rules + +Always use Conventional Commits. + +Format: + +``` +type(scope): description +``` + +Examples: + +``` +feat(core): add image loader + +feat(alpha): implement alpha recovery + +fix(validation): handle dimension mismatch + +test(core): add image validation tests + +docs(readme): update usage examples +``` + +Avoid: + +``` +update code + +changes + +fix stuff + +work +``` + +Every commit must represent one logical change. + +--- + +# Coding Principles + +Prefer: + +- TypeScript strict mode. +- Small functions. +- Pure functions. +- Explicit types. +- Immutable data. +- Clear naming. +- Composition over inheritance. + +Avoid: + +- `any`. +- Hidden side effects. +- Global mutable state. +- Unnecessary abstractions. +- Premature optimization. + +--- + +# Dependencies + +Before adding a dependency: + +Ask: + +- Is this solving a real problem? +- Is the dependency actively maintained? +- Can this be implemented simply internally? +- Does it increase project complexity? + +Avoid adding libraries without justification. + +--- + +# Mathematical Code + +AlphaForge contains scientific and mathematical processing. + +For algorithms: + +Always document: + +- formula used. +- assumptions. +- input/output behavior. +- numerical limitations. + +Never replace a mathematically correct solution with a visual approximation without justification. + +--- + +# Image Processing Rules + +Never: + +- use machine learning segmentation. +- use chroma key extraction. +- guess transparency. +- silently modify pixels. + +Prefer: + +- deterministic algorithms. +- measurable transformations. +- reproducible output. + +--- + +# Testing Strategy + +AlphaForge uses multiple verification layers: + +## Unit Testing + +Used for: + +- deterministic examples +- expected mathematical cases +- validation behavior +- error handling + +## Integration Testing + +Used for: + +- pipeline compatibility +- module interaction +- end-to-end flows + +## Property-Based Testing + +Required when implementing: + +- mathematical algorithms +- numerical transformations +- image processing operations +- deterministic pipelines + +Property tests should verify invariants instead of specific examples. + +Typical invariants: + +- determinism +- immutability +- numerical safety +- valid ranges +- structural preservation + +--- + +# Testing Requirements + +Before considering a task complete: + +Ensure: + +- Code compiles. +- Tests pass. +- New functionality has tests. +- Existing behavior is preserved. + +Critical image algorithms require: + +- unit tests. +- regression tests. +- sample images. + +--- + +# Documentation Requirements + +Public functionality requires documentation. + +Document: + +- purpose. +- usage. +- limitations. +- examples. + +Complex mathematical features require explanation. + +--- + +# Error Handling + +Errors must be explicit. + +Never silently ignore: + +- invalid images. +- incompatible dimensions. +- corrupted data. +- unsupported formats. + +Provide useful error messages. + +--- + +# When Unsure + +Do not guess. + +If a decision affects: + +- architecture. +- public API. +- dependencies. +- algorithms. + +Stop and explain: + +- the problem. +- possible solutions. +- trade-offs. +- recommendation. + +--- + +# Keep The Project Healthy + +Always prefer: + +A small correct improvement + +over + +A large incomplete implementation. + +Leave the repository better than you found it. + +--- + +# Current Project Priority + +The immediate objective is building the foundation: + +1. Stable project setup. +2. Image loading. +3. Validation pipeline. +4. Color science layer. +5. Alpha reconstruction core. +6. Testing infrastructure. +7. CLI. +8. Documentation. + +Do not skip foundational steps. + +--- + +# Final Rule + +Before writing code ask: + +"Does this make AlphaForge more reliable, deterministic and maintainable?" + +If the answer is no, do not implement it. diff --git a/CODE_OF_CONDUCT.md b/CODE_OF_CONDUCT.md new file mode 100644 index 0000000..0e650c9 --- /dev/null +++ b/CODE_OF_CONDUCT.md @@ -0,0 +1,45 @@ +# Contributor Covenant Code of Conduct + +## Our Pledge + +We as members, contributors, and leaders pledge to make participation in our community a harassment-free experience for everyone, regardless of age, body size, visible or invisible disability, ethnicity, sex characteristics, gender identity and expression, level of experience, education, socio-economic status, nationality, personal appearance, race, religion, or sexual identity and orientation. + +We pledge to act and interact in ways that contribute to an open, welcoming, diverse, inclusive, and healthy community. + +## Our Standards + +Examples of behavior that contributes to a positive environment for our community include: + +- Demonstrating empathy and kindness toward other people. +- Being respectful of differing opinions, viewpoints, and experiences. +- Giving and gracefully accepting constructive feedback. +- Accepting responsibility and apologizing to those affected by our mistakes, and learning from the experience. +- Focusing on what is best not just for us as individuals, but for the overall community. + +Examples of unacceptable behavior include: + +- The use of sexualized language or imagery, and sexual attention or advances of any kind. +- Trolling, insulting or derogatory comments, and personal or political attacks. +- Public or private harassment. +- Publishing others' private information, such as a physical or email address, without their explicit permission. +- Other conduct which could reasonably be considered inappropriate in a professional setting. + +## Enforcement Responsibilities + +Community leaders are responsible for clarifying and enforcing our standards of acceptable behavior and will take appropriate and fair corrective action in response to any behavior that they deem inappropriate, threatening, offensive, or harmful. + +Community leaders have the right and responsibility to remove, edit, or reject comments, commits, code, wiki edits, issues, and other contributions that are not aligned to this Code of Conduct, and will communicate reasons for moderation decisions when appropriate. + +## Scope + +This Code of Conduct applies within all community spaces, and also applies when an individual is officially representing the community in public spaces. + +## Enforcement + +Instances of abusive, harassing, or otherwise unacceptable behavior may be reported to the community leaders at **hello@maucabrera.dev**. + +All complaints will be reviewed and investigated promptly and fairly. + +## Attribution + +This Code of Conduct is adapted from the [Contributor Covenant](https://www.contributor-covenant.org), version 2.1. diff --git a/CONSTITUTION.md b/CONSTITUTION.md new file mode 100644 index 0000000..9ed47fd --- /dev/null +++ b/CONSTITUTION.md @@ -0,0 +1,342 @@ +# CONSTITUTION.md + +# AlphaForge Constitution + +> The fundamental principles that govern the evolution of AlphaForge. + +This document defines the permanent values of the project. + +Architectures may evolve. + +Algorithms may improve. + +Implementations may change. + +These principles should not. + +Any proposal that conflicts with this constitution should be rejected unless the constitution itself is intentionally amended. + +--- + +# Article I — Mission + +AlphaForge exists to transform AI-generated images into production-ready digital assets through deterministic post-processing. + +The project does not generate images. + +The project guarantees technical correctness after image generation. + +--- + +# Article II — Determinism First + +Every algorithm must be deterministic. + +Given identical inputs and identical configuration, AlphaForge must always produce identical outputs. + +Randomness is forbidden. + +Probabilistic behavior is forbidden. + +Machine learning inference is forbidden unless explicitly introduced as an optional independent module that never replaces deterministic algorithms. + +--- + +# Article III — Mathematics Over Heuristics + +Whenever an exact mathematical solution exists, it must be preferred over heuristic approximations. + +Approximation should only be used when: + +- no analytical solution exists; +- the trade-offs are documented; +- accuracy is measurable; +- deterministic behavior is preserved. + +The project should calculate whenever possible, never guess. + +--- + +# Article IV — Production Quality + +Every feature must target professional production pipelines. + +Generated outputs should be suitable for direct use in: + +- games; +- user interfaces; +- automation pipelines; +- digital publishing; +- commercial software. + +Manual post-processing should never be required if deterministic processing can solve the problem. + +--- + +# Article V — Modular Architecture + +Each module must solve one problem exceptionally well. + +Modules should remain: + +- cohesive; +- loosely coupled; +- independently testable; +- reusable. + +The architecture should favor composition over inheritance. + +Dependencies should always point inward. + +--- + +# Article VI — Explicitness + +Hidden behavior is discouraged. + +APIs should be predictable. + +Configuration should be explicit. + +Side effects should be minimized. + +Magic should be avoided. + +Developers should always understand why the library behaves as it does. + +--- + +# Article VII — Correctness Before Performance + +Performance matters. + +Correctness matters more. + +An optimization that changes the mathematical correctness of the output must never be accepted without overwhelming justification. + +If forced to choose, AlphaForge always prefers correctness. + +--- + +# Article VIII — Stability + +Public APIs are contracts. + +Breaking changes should be rare. + +Backward compatibility should be preserved whenever practical. + +Major breaking changes require a major version increment. + +--- + +# Article IX — Testing + +Every feature must be testable. + +Every bug should produce a regression test. + +Critical algorithms require: + +- unit tests; +- integration tests; +- golden image tests; +- edge-case coverage. + +Untested code should not be considered complete. + +--- + +# Article X — Documentation + +Documentation is part of the product. + +Every public feature should include documentation. + +Complex algorithms should explain: + +- the mathematical model; +- assumptions; +- limitations; +- references; +- implementation notes. + +If a feature cannot be explained clearly, it is probably too complex. + +--- + +# Article XI — Open Source + +The project exists for the community. + +Code should optimize for readability before cleverness. + +Contributors should leave the project in a better state than they found it. + +Constructive discussion is preferred over personal preference. + +Technical decisions should be documented. + +--- + +# Article XII — Simplicity + +Complexity is a cost. + +Every abstraction must justify its existence. + +Every dependency should solve a real problem. + +Every additional line of code creates future maintenance. + +The simplest correct solution should always be preferred. + +--- + +# Article XIII — Reliability + +AlphaForge should be a library developers trust. + +Reliability is measured by: + +- deterministic behavior; +- reproducibility; +- mathematical correctness; +- predictable APIs; +- comprehensive testing. + +Trust takes years to build and minutes to lose. + +--- + +# Article XIV — Evolution + +The project should evolve carefully. + +Features are permanent maintenance obligations. + +New functionality should only be accepted when it clearly advances the project's mission. + +The roadmap should favor refinement over feature accumulation. + +Quality is more important than quantity. + +--- + +# Article XV — Artificial Intelligence + +AlphaForge complements AI. + +It does not replace it. + +AI is responsible for creativity. + +AlphaForge is responsible for technical precision. + +Whenever deterministic computation can solve a problem, deterministic computation should be preferred over AI inference. + +--- + +# Article XVI — Engineering Standards + +The project follows professional software engineering practices. + +Development should prioritize: + +- maintainability; +- readability; +- consistency; +- reproducibility; +- automation. + +Engineering discipline should always outweigh convenience. + +--- + +# Article XVII — Code Quality + +Code should be written for humans first. + +Optimizations must never obscure correctness. + +Functions should be small. + +Names should be explicit. + +Duplication should be minimized. + +Side effects should be isolated. + +Technical debt should never be intentionally accumulated. + +--- + +# Article XVIII — Decision Making + +Architectural decisions should be evidence-based. + +Whenever possible, decisions should rely on: + +- mathematics; +- benchmarks; +- reproducible experiments; +- academic literature; +- measurable results. + +Opinion alone is insufficient. + +--- + +# Article XIX — Long-Term Vision + +AlphaForge should become the reference implementation for deterministic post-processing of AI-generated assets. + +Every release should move the project closer to that goal. + +The project should remain useful for years, not trends. + +--- + +# Article XX — Verification Through Properties + +Algorithms that transform mathematical data must prove their invariants. + +Correctness is defined not only by expected examples, but also by guarantees that remain true across the valid input space. + +Property-based testing is a tool to verify those guarantees. + +--- + +# Article XXI — Immutable Mathematical Core + +The mathematical reconstruction engine is the immutable foundation of AlphaForge. + +It encodes the physical model of image formation and the deterministic algorithms that invert that model. + +Once accepted, the reconstruction engine must remain stable and unchanged except to fix demonstrable mathematical defects. + +Visual quality improvements, convenience features, and optional refinements must remain external to the reconstruction engine. + +They must be opt-in, composable, and must never alter the deterministic output of the core model. + +--- + +# Final Principle + +When uncertainty exists, choose the solution that best satisfies the following priorities: + +1. Mathematical correctness. +2. Determinism. +3. Reliability. +4. Simplicity. +5. Maintainability. +6. Developer experience. +7. Performance. + +Never invert this order. + +--- + +# Project Motto + +> Deterministic precision over probabilistic guesswork. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md new file mode 100644 index 0000000..2fb59f4 --- /dev/null +++ b/CONTRIBUTING.md @@ -0,0 +1,55 @@ +# Contributing to AlphaForge + +Thank you for your interest in contributing to AlphaForge. + +## Getting Started + +AlphaForge uses [pnpm](https://pnpm.io), Node.js 20+, and TypeScript. + +```bash +pnpm install +pnpm run build +pnpm run test +``` + +## Development Workflow + +1. Open an issue or discussion before proposing large changes. +2. Create a short-lived branch from `develop`: + - `feature/` for new work. + - `fix/` for bug fixes. +3. Make focused, logically separated commits following [Conventional Commits](https://www.conventionalcommits.org/). +4. Ensure all checks pass: + ```bash + pnpm run format:check + pnpm run lint + pnpm run typecheck + pnpm run test + pnpm run build + ``` +5. Open a pull request against `develop`. + +## Contribution Standards + +- Follow the principles in `CONSTITUTION.md`. +- Respect the architecture in `ARCHITECTURE.md`. +- Only change public APIs after discussion; the public API is defined in `API.md`. +- Document architectural decisions in `ADR.md`. +- Update `TASKS.md` when roadmap items are completed or changed. +- Keep the mathematical reconstruction engine unchanged unless fixing a demonstrable defect. + +## Testing + +- Add unit tests for new behavior. +- Add integration tests for pipeline changes. +- Add property-based tests for mathematical transformations. +- Every bug fix should include a regression test. + +## Documentation + +- Update relevant documentation for public API changes. +- Keep `README.md`, `API.md`, `ARCHITECTURE.md`, and `PROJECT.md` consistent with the implementation. + +## Questions + +Open an issue or start a discussion on GitHub. diff --git a/LICENSE b/LICENSE index fdddb29..3e4b9ae 100644 --- a/LICENSE +++ b/LICENSE @@ -1,24 +1,21 @@ -This is free and unencumbered software released into the public domain. +MIT License -Anyone is free to copy, modify, publish, use, compile, sell, or -distribute this software, either in source code form or as a compiled -binary, for any purpose, commercial or non-commercial, and by any -means. +Copyright (c) 2026 Mauricio Cabrera -In jurisdictions that recognize copyright laws, the author or authors -of this software dedicate any and all copyright interest in the -software to the public domain. We make this dedication for the benefit -of the public at large and to the detriment of our heirs and -successors. We intend this dedication to be an overt act of -relinquishment in perpetuity of all present and future rights to this -software under copyright law. +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: -THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, -EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF -MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. -IN NO EVENT SHALL THE AUTHORS BE LIABLE FOR ANY CLAIM, DAMAGES OR -OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, -ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR -OTHER DEALINGS IN THE SOFTWARE. +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. -For more information, please refer to +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/PROJECT.md b/PROJECT.md new file mode 100644 index 0000000..e400b8c --- /dev/null +++ b/PROJECT.md @@ -0,0 +1,336 @@ +# PROJECT.md + +# AlphaForge + +> Deterministic post-processing for AI-generated production assets. + +--- + +# Vision + +AlphaForge is an open-source toolkit that bridges the gap between generative AI and professional production pipelines. + +Modern AI image models can create extraordinary artwork, but they still lack many of the deterministic guarantees required for professional workflows. + +AlphaForge exists to solve this problem. + +Instead of generating images, AlphaForge transforms AI-generated assets into production-ready deliverables through mathematically correct, deterministic image processing. + +The long-term goal is to become the reference open-source toolkit for post-processing AI-generated assets. + +--- + +# Mission + +Our mission is simple: + +> Transform AI-generated images into production-ready digital assets using deterministic algorithms instead of visual guesswork. + +Every algorithm implemented by AlphaForge should be: + +- mathematically correct +- deterministic +- reproducible +- production-ready +- extensively tested +- independent from machine learning inference + +--- + +# Project Documentation + +This repository keeps its documentation close to the code so that both humans and AI assistants can rely on a single source of truth. + +- **PROJECT.md** defines the project vision, mission, scope, goals, and success criteria. +- **CONSTITUTION.md** defines the permanent values and non-negotiable principles that govern every decision. +- **ARCHITECTURE.md** describes the high-level architecture, module responsibilities, dependency rules, and data flow. +- **API.md** is the canonical registry of the public API. Any export not listed there is internal. +- **ADR.md** records accepted architectural decisions in a lightweight format. +- **TASKS.md** tracks the roadmap, milestones, and current priorities. +- **CLAUDE.md** contains the development workflow for AI coding assistants. +- **README.md** is the end-user quick-start and contribution entry point. +- **CHANGELOG.md** tracks project history and releases. +- **docs/specifications/** contains the formal technical specifications for the reconstruction pipeline: + - `alpha-reconstruction.md` — normative alpha reconstruction specification. + - `foreground-reconstruction.md` — normative foreground color recovery specification. + - `cleanup.md` — normative cleanup and post-processing specification. + - `validation.md` — normative input validation specification. + - `png-export.md` — normative PNG export specification. + +--- + +# Current Status + +AlphaForge Core is **frozen**. + +The deterministic reconstruction pipeline is implemented and tested: image loading, input validation, optional background validation, linear RGB color conversion, Porter-Duff alpha reconstruction, symmetric foreground reconstruction, optional cleanup, and PNG export. The public API surface is frozen and documented in `API.md`. + +Future work will add optional, composable refinement layers outside the core engine. + +--- + +# Milestones + +The following milestones are complete: + +- Foundation: project bootstrap, TypeScript, pnpm, ESLint, Vitest, GitHub Actions. +- Image I/O: image loading, metadata validation, dimension validation. +- Color Science: sRGB ↔ linear RGB conversion. +- Technical Specifications: reconstruction, cleanup, validation, and export specifications. +- Alpha Reconstruction: deterministic Porter-Duff alpha recovery. +- Foreground Reconstruction: symmetric foreground color recovery. +- Cleanup Pipeline: alpha thresholding, noise removal, morphological cleanup. +- PNG Export: RGBA8 PNG export using the existing color conversion boundary. +- Pipeline Orchestration: end-to-end reconstruction pipeline. +- Core Mathematical Hardening: observation-order invariance for foreground reconstruction. +- Background Validation Hardening: optional declared-vs-measured background color validation. + +The active milestone is **Core Stabilization / Core Freeze**, which finalizes the Core API and freezes the mathematical reconstruction engine. + +--- + +# The Problem + +Modern image generation models are becoming extraordinarily capable. + +However, they still present limitations that make them difficult to integrate into professional pipelines. + +Examples include: + +- Missing alpha channels +- Edge contamination +- Background color bleeding +- Geometry drift during edits +- Non-repeatable edits +- Inconsistent transparency +- Color contamination +- Poor validation capabilities + +Traditional solutions such as chroma keying, threshold selection or AI segmentation often introduce additional artifacts and cannot guarantee deterministic results. + +Professional production pipelines require precision. + +--- + +# Our Philosophy + +AI should be responsible for creativity. + +AlphaForge should be responsible for precision. + +Generative models create. + +AlphaForge validates. + +Generative models imagine. + +AlphaForge guarantees. + +--- + +# Core Principles + +The permanent principles that govern AlphaForge are defined in `CONSTITUTION.md`. + +The project is committed to determinism, mathematical correctness over heuristics, production quality, modular architecture, and stable public APIs. + +--- + +# Current Scope + +The initial version of AlphaForge focuses on deterministic alpha reconstruction using two AI-generated renders. + +Pipeline: + +AI Render (White Background) +│ +▼ +AI Render (Near-Black Background) +│ +▼ +Image Validation +│ +▼ +Linear RGB Conversion +│ +▼ +Mathematical Alpha Reconstruction +│ +▼ +Foreground Color Recovery +│ +▼ +Optional Cleanup +│ +▼ +PNG Export + +This workflow eliminates the need for chroma key techniques while preserving anti-aliasing, semi-transparent pixels, shadows and fine edge details. + +--- + +# Future Scope + +Alpha reconstruction is only the first capability. + +Future versions may include additional deterministic processing modules, including: + +- Geometry verification +- Difference visualization +- Batch processing +- Automatic quality reports +- Image alignment verification +- Advanced cleanup filters +- Optional refinement subsystem +- Asset optimization +- WebAssembly support +- GPU acceleration + +Every new feature must align with the project's core philosophy. + +--- + +# Project Goals + +## Primary Goals + +- Recover mathematically accurate alpha channels. +- Preserve original image quality. +- Eliminate chroma key workflows. +- Validate AI-generated assets before processing. +- Build a reliable production pipeline. +- Provide an intuitive TypeScript API. +- Offer a professional CLI. +- Maintain comprehensive automated testing. + +--- + +## Secondary Goals + +- High performance. +- Extensive documentation. +- Visual debugging tools. +- Batch processing. +- Browser compatibility. +- NPM distribution. +- Long-term API stability. + +--- + +# Non Goals + +AlphaForge is intentionally **not**: + +- an image editor +- a Photoshop replacement +- an AI image generator +- a segmentation model +- a background removal AI +- a computer vision framework +- a general image manipulation library + +The project focuses exclusively on deterministic post-processing of AI-generated assets. + +--- + +# Intended Users + +AlphaForge is designed for: + +- Game developers +- Technical artists +- AI artists +- Pipeline engineers +- Tool developers +- UI designers +- Automation engineers +- Open-source contributors + +--- + +# Design Principles + +Every feature should follow these priorities. They are a concrete expression of the higher-level principles in `CONSTITUTION.md`. + +1. Correctness before performance. +2. Simplicity before complexity. +3. Readability before cleverness. +4. Explicitness before magic. +5. Composition before inheritance. +6. Pure functions whenever possible. +7. Stable APIs over rapid changes. + +--- + +# Quality Standards + +No feature is considered complete until it satisfies: + +- deterministic output +- automated tests +- documentation +- benchmark coverage (when applicable) +- type safety +- code review +- production readiness + +--- + +# Open Source Philosophy + +AlphaForge is built as a community project. + +We welcome contributions that improve: + +- mathematical correctness +- architecture +- performance +- documentation +- developer experience +- reliability +- testing + +Every significant technical decision should be documented. + +Every contribution should leave the project better than it was found. + +--- + +# Success Criteria + +AlphaForge will be considered successful when developers trust it as the default solution for deterministic post-processing of AI-generated assets. + +Success is measured by: + +- reliability +- correctness +- maintainability +- community adoption +- documentation quality +- production usage + +Not by the number of features. + +--- + +# Long-Term Vision + +We envision AlphaForge becoming the standard toolkit that developers integrate after AI image generation and before production deployment. + +Whether the asset is: + +- a game icon, +- a cosmetic item, +- a UI component, +- a trading card, +- a logo, +- an illustration, +- or any future AI-generated asset, + +AlphaForge should ensure that it is technically correct, production-ready and fully deterministic. + +--- + +# Project Motto + +> AI creates. AlphaForge perfects. diff --git a/README.md b/README.md new file mode 100644 index 0000000..a589ccd --- /dev/null +++ b/README.md @@ -0,0 +1,110 @@ +# AlphaForge + +Deterministic post-processing for AI-generated production assets. + +## About + +AlphaForge transforms AI-generated images into production-ready digital assets using mathematically correct, deterministic algorithms. It does not generate images, and it does not rely on machine learning inference. + +## Current Status + +AlphaForge Core is **frozen**. The deterministic reconstruction pipeline is implemented and tested, the public API is declared stable, and future visual improvements will be implemented as optional, composable refinement layers outside the core engine. + +## Installation + +```bash +npm install alphaforge +``` + +## Usage + +```ts +import { reconstructPipeline } from "alphaforge"; + +await reconstructPipeline({ + observationAPath: "./render-white.png", + observationBPath: "./render-black.png", + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + outputPath: "./result.png", +}); +``` + +Only the symbols exported from the package root are public. Internal modules are not covered by the stability contract. + +## Project Documentation + +For a full overview of the project, architecture, public API, accepted decisions, and formal specifications, see `PROJECT.md`. It is the source of truth for the documentation map. + +Key documents: + +- `PROJECT.md` — vision, mission, scope, goals, and documentation map. +- `CONSTITUTION.md` — long-term project principles. +- `ARCHITECTURE.md` — system architecture and module responsibilities. +- `API.md` — canonical registry of the public API. +- `ADR.md` — architecture decision records. +- `TASKS.md` — roadmap and milestones. +- `CHANGELOG.md` — project history. +- `docs/specifications/` — normative technical specifications for the reconstruction pipeline. + +## Development + +This project uses [pnpm](https://pnpm.io), Node.js 20+, and TypeScript. + +### Setup + +```bash +pnpm install +``` + +### Available Scripts + +| Script | Description | +| ----------------------- | ----------------------------- | +| `pnpm run build` | Compile TypeScript to `dist/` | +| `pnpm run typecheck` | Run TypeScript type checking | +| `pnpm run test` | Run tests | +| `pnpm run lint` | Run ESLint | +| `pnpm run format:check` | Check Prettier formatting | + +### Workflow + +This repository uses the following branching strategy: + +- `main` — production-ready state. +- `develop` — integration branch for features. +- `feature/*` and `fix/*` — short-lived work branches. + +Branches are squash merged into `develop`. Releases are prepared by merging `develop` into `main`. + +### Contributing + +Contributions should follow the principles defined in `CONSTITUTION.md`, the architecture in `ARCHITECTURE.md`, the public API contract in `API.md`, the accepted decisions in `ADR.md`, and the development guidelines in `CLAUDE.md`. + +### Testing Philosophy + +AlphaForge combines unit testing, integration testing, and property-based testing to verify deterministic behavior and mathematical correctness. + +## Maturity + +AlphaForge is pre-1.0. The Core mathematical engine and the public API are frozen as of v0.9.0. Necessary corrections to demonstrable defects may still occur under the 0.x line, but the reconstruction pipeline will not change for visual preference or convenience. + +Future improvements, including CLI support, automatic background inference, and edge-aware refinement, will be implemented as optional layers outside the core engine. + +## Limitations + +- **No CLI yet.** The current release is a TypeScript library only. +- **No automatic background inference.** Background colors must be declared by the caller; optional background validation can detect mismatches. +- **Refinement features are not part of the core.** Edge refinement, halo reduction, and color decontamination are planned for future milestones. + +## Maintainer + +Maintained by **Mauricio Cabrera**. + +- Website: [https://maucabrera.dev](https://maucabrera.dev) +- GitHub: [https://github.com/maucabreradev](https://github.com/maucabreradev) +- Email: [hello@maucabrera.dev](mailto:hello@maucabrera.dev) + +## License + +[MIT](LICENSE) diff --git a/SECURITY.md b/SECURITY.md new file mode 100644 index 0000000..4ad7725 --- /dev/null +++ b/SECURITY.md @@ -0,0 +1,26 @@ +# Security Policy + +## Supported Versions + +The following versions of AlphaForge receive security updates: + +| Version | Supported | +| ------- | ------------------ | +| 0.9.x | :white_check_mark: | +| < 0.9.0 | :x: | + +## Reporting a Vulnerability + +If you discover a security vulnerability in AlphaForge, please report it privately. + +- Email: **hello@maucabrera.dev** +- Do not open a public issue for security vulnerabilities. + +Please include the following in your report: + +- A clear description of the vulnerability. +- Steps to reproduce it, if applicable. +- Affected versions. +- Any suggested remediation. + +You can expect an acknowledgment within 7 days. We will work with you to validate the issue and coordinate a fix. diff --git a/TASKS.md b/TASKS.md new file mode 100644 index 0000000..ea92e7b --- /dev/null +++ b/TASKS.md @@ -0,0 +1,150 @@ +# AlphaForge Roadmap + +## Milestone 1 — Foundation + +- [x] Bootstrap project +- [x] Configure pnpm +- [x] Configure TypeScript +- [x] Configure ESLint +- [x] Configure Vitest +- [x] Configure GitHub Actions + +--- + +## Milestone 2 — Image I/O + +- [x] Image loading +- [x] Metadata validation +- [x] Dimension validation +- [x] Validation error hierarchy +- [x] Validation test suite + +--- + +## Milestone 3 — Color Science + +- [x] sRGB → Linear RGB +- [x] Linear RGB → sRGB +- [x] Color conversion tests + +--- + +## Milestone 4 — Technical Specification + +- [x] Alpha reconstruction specification +- [x] Foreground reconstruction specification +- [x] Numerical stability specification +- [x] Validation strategy +- [x] Cleanup strategy +- [x] Technical review + +--- + +## Milestone 5 — Alpha Reconstruction + +- [x] Alpha reconstruction + +--- + +## Milestone 6 — Foreground Reconstruction + +- [x] Foreground reconstruction + +--- + +## Milestone 7 — Cleanup Pipeline + +- [x] Cleanup pipeline architecture +- [x] Alpha thresholding +- [x] Noise removal +- [x] Morphological cleanup +- [x] Property-based testing +- [x] Cleanup integration tests + +--- + +## Milestone 8 — PNG Export + +- [x] PNG export architecture +- [x] PNG export specification +- [x] PNG exporter implementation +- [x] Export tests + +--- + +## Milestone 8.5 — Pipeline Orchestration + +- [x] Pipeline orchestration architecture +- [x] Reconstruction pipeline implementation +- [x] Property-based verification for existing mathematical modules +- [x] Pipeline integration tests + +--- + +## Milestone 8.5.1 — Core Mathematical Hardening + +- [x] Order-invariance audit +- [x] Foreground reconstruction order-invariance fix +- [x] Regression tests for observation swapping +- [x] Documentation update for dual-observation reconstruction + +--- + +## Milestone 8.5.2 — Background Validation Hardening + +- [x] Add background validation module +- [x] Detect declared-vs-measured background color mismatches +- [x] Implement typed BackgroundMismatchError +- [x] Compare declared and measured colors in linear RGB space +- [x] Make validation optional and explicit in the pipeline +- [x] Add unit tests for border sampling and mismatch detection +- [x] Add pipeline integration tests for background validation +- [x] Add property-based tests for deterministic validation output +- [x] Update validation specification +- [x] Update API documentation + +--- + +## Milestone 8.6 — Core Stabilization / Core Freeze + +- [x] Complete architecture audit +- [x] Mathematical consistency review +- [x] API review +- [x] Error hierarchy review +- [x] Core Freeze documentation and governance alignment +- [x] Official public API freeze + +--- + +## Milestone 8.7 — Refinement Engine + +- [ ] Optional deterministic background analysis +- [ ] Optional background inference pipeline integration +- [ ] Background detection tests and documentation +- [ ] Edge refinement +- [ ] Halo reduction +- [ ] Color decontamination + +--- + +## Milestone 9 — CLI + +- [ ] Command line interface + +--- + +## Milestone 10 — Documentation + +- [ ] User documentation +- [ ] API documentation +- [ ] Examples +- [ ] Benchmarks + +--- + +## Milestone 11 — v1.0 + +- [ ] Release Candidate +- [ ] Performance validation +- [ ] Cross-platform validation +- [ ] v1.0.0 diff --git a/docs/specifications/alpha-reconstruction.md b/docs/specifications/alpha-reconstruction.md new file mode 100644 index 0000000..52e9f2d --- /dev/null +++ b/docs/specifications/alpha-reconstruction.md @@ -0,0 +1,423 @@ +# Alpha Reconstruction Specification + +## AlphaForge Reconstruction Pipeline — Part 1 + +**Version:** 1.0.0 + +**Status:** Stable. This document defines normative requirements for the +AlphaForge alpha reconstruction stage. + +--- + +## 1. Abstract + +This document specifies the alpha reconstruction stage of the AlphaForge +reconstruction pipeline. Alpha reconstruction recovers a per-pixel opacity value +from two aligned source images of the same foreground rendered against two +distinct, known background colors. + +The algorithm is derived from the Porter-Duff "over" compositing operator. It +operates in linear RGB color space and does not use chroma-key extraction, +machine-learning segmentation, or RGB-distance heuristics. + +--- + +## 2. Conformance + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119. + +Sections explicitly marked **(Normative)** define mandatory behavior. + +Sections explicitly marked **(Informative)** provide rationale, examples, future +direction, and non-binding guidance. + +--- + +## 3. Scope + +**(Normative)** + +This specification applies to the AlphaForge alpha reconstruction stage only. + +The alpha reconstruction stage MUST: + +- Accept two aligned source images and two associated background color values. +- Produce a single scalar alpha channel representing the recovered opacity of + the foreground. +- Operate in linear RGB color space. +- Be deterministic: identical inputs MUST produce identical outputs. + +The alpha reconstruction stage MUST NOT: + +- Use chroma-key extraction. +- Use machine-learning inference. +- Use RGB-distance or color-similarity heuristics as a replacement for the + compositing equation. +- Invent or hallucinate information not present in the source images. + +--- + +## 4. Mathematical Foundations + +**(Normative)** + +### 4.1 Color representation + +All color values are represented as three-component vectors in linear RGB +space: + +``` +C = (C_r, C_g, C_b) +``` + +Each component is a real number in the range [0, 1]. + +The alpha value α is a scalar in the range [0, 1]. + +### 4.2 Physical compositing model + +The source image is modeled as the result of the Porter-Duff "over" operator: + +``` +C = αF + (1 - α)B +``` + +where: + +- `C` is the observed composite color. +- `F` is the foreground color. +- `B` is the known background color. +- `α` is the scalar opacity of the foreground. + +This equation is applied per pixel. The compositing operation is linear and MUST +be performed on linear RGB values. Applying it to gamma-encoded sRGB values +produces mathematically incorrect results. + +### 4.3 Two-observation model + +AlphaForge receives two aligned observations of the same foreground: + +``` +C1 = αF + (1 - α)B1 +C2 = αF + (1 - α)B2 +``` + +where `B1` and `B2` are the two known background colors. + +Subtracting the second equation from the first eliminates the unknown foreground +`F`: + +``` +C1 - C2 = (1 - α)(B1 - B2) +``` + +### 4.4 Per-channel alpha estimate + +Rearranging the two-observation model yields an independent alpha estimate for +each color channel `c` ∈ {r, g, b}: + +``` +α_c = 1 - (C1_c - C2_c) / (B1_c - B2_c) +``` + +A mandatory minimum denominator `EPSILON` is REQUIRED to prevent division by zero +or by near-zero values that amplify quantization noise: + +``` +EPSILON = 1e-6 +``` + +This estimate is valid only when the absolute denominator satisfies: + +``` +|B1_c - B2_c| > EPSILON +``` + +For each channel `c`, the alpha estimate `α_c` is defined as: + +``` +α_c = 1 - (C1_c - C2_c) / (B1_c - B2_c) +``` + +provided `|B1_c - B2_c| > EPSILON`. + +### 4.5 Scalar alpha aggregation + +Because alpha is a scalar property of the pixel, the three per-channel estimates +MUST be aggregated into a single deterministic scalar alpha value. + +A deterministic aggregation strategy MUST be applied. The exact strategy +(e.g., mean, median, weighted average, or a robust estimator) is an implementation +detail that MUST be selected by the implementation and documented in its own +configuration or policy document. + +The chosen strategy MUST satisfy the following invariants: + +- It MUST be deterministic for identical inputs. +- It MUST produce a single scalar alpha value per pixel. +- It MUST produce a value in the range [0, 1] before any final clamping, unless + numerical error requires clamping as defined in Section 6. + +--- + +## 5. Assumptions + +**(Normative)** + +The following assumptions are preconditions for the alpha reconstruction stage. +An implementation MAY reject input that violates these assumptions. + +### 5.1 Geometric alignment + +The two source images MUST represent the same pixel geometry. The foreground +object MUST appear at the same pixel coordinates in both images. + +### 5.2 Identical dimensions + +The two source images MUST have identical width, height, and channel count. + +### 5.3 Known background colors + +The background colors `B1` and `B2` MUST be known and provided as input to the +algorithm. They MUST be substantially different. Substantially different means +that at least one color channel satisfies: + +``` +|B1_c - B2_c| > EPSILON +``` + +where `EPSILON` is the mandatory minimum denominator defined in Section 4.4. + +### 5.4 Linear RGB input + +The input observations `C1` and `C2` MUST be expressed in linear RGB color space +before the alpha reconstruction equation is applied. An implementation MUST +perform gamma decoding from any source color space (e.g., sRGB) before applying +the equations in this specification. + +### 5.5 Foreground consistency + +The foreground color `F` and opacity `α` MUST be identical between the two +observations, except for changes caused by the background color itself. + +### 5.6 Background uniformity + +Within each source image, the background color MUST be uniform. Local +variations in the background color are not modeled by this specification. + +### 5.7 No secondary effects + +The model does not account for reflections, refractions, translucent shadows, or +other background-dependent effects on the foreground appearance. An +implementation MUST document any such deviations from the physical model. + +--- + +## 6. Numerical Stability + +**(Normative)** + +### 6.1 Division by zero + +The mandatory minimum denominator `EPSILON` defined in Section 4.4 MUST be used +as the threshold for division safety. + +If `|B1_c - B2_c| ≤ EPSILON` for a channel `c`, then the per-channel alpha +estimate for that channel MUST be excluded from the aggregation, or the +implementation MUST treat the channel as having no usable signal. + +If all three channels have `|B1_c - B2_c| ≤ EPSILON`, the implementation MUST +report an error and MUST NOT produce a reconstructed alpha value. + +### 6.2 Clamping + +After aggregation and before output, the alpha value MUST be strictly clamped +to the physical range [0.0, 1.0]: + +``` +α_out = clamp(α_agg, 0.0, 1.0) +``` + +Any clamping operation MUST be documented in the implementation's numerical +stability policy. + +### 6.3 Floating-point precision + +All intermediate calculations SHOULD be performed using a floating-point +representation that preserves precision for the intended output bit depth. For +8-bit output, single-precision IEEE 754 floating-point arithmetic is sufficient. + +### 6.4 Near-zero alpha + +For pixels where `α_out` is exactly 0, the corresponding foreground color is not +defined by the alpha reconstruction equation. Foreground reconstruction is +covered by a separate specification. + +--- + +## 7. Required Inputs + +**(Normative)** + +The alpha reconstruction stage MUST accept the following inputs: + +1. **Observation 1** — an image `C1` in linear RGB color space. +2. **Observation 2** — an image `C2` in linear RGB color space. +3. **Background color 1** — a linear RGB color value `B1`. +4. **Background color 2** — a linear RGB color value `B2`. +5. **Aggregation policy** — a deterministic policy describing how per-channel + alpha estimates are combined into a scalar alpha value. + +The implementation MAY accept additional configuration parameters (e.g., +output bit depth) provided that their behavior is documented. The value of +`EPSILON` is mandatory and MUST NOT be configurable. + +--- + +## 8. Required Outputs + +**(Normative)** + +The alpha reconstruction stage MUST produce: + +1. **Alpha channel** — a scalar value `α_out` for every pixel, in the range + [0, 1]. + +The output format MAY be an image of the same width and height as the inputs, +containing one alpha channel per pixel. The channel layout and bit depth are +implementation-defined. + +--- + +## 9. Deterministic Behavior + +**(Normative)** + +A conforming implementation MUST be deterministic: + +- For any fixed set of inputs, the output alpha channel MUST be identical across + runs on the same implementation. +- The aggregation policy MUST be deterministic. +- The order of operations MUST be deterministic. +- Floating-point operations MUST use the same precision and rounding mode on the + same platform. + +Portability across different hardware or floating-point units is an informative +goal but is not a normative requirement. + +--- + +## 10. Error Handling + +**(Normative)** + +The alpha reconstruction stage MUST detect and report the following error +conditions: + +1. **Dimension mismatch** — the two input images do not have identical width and + height. +2. **Insufficient background difference** — `|B1_c - B2_c| ≤ EPSILON` for all channels. +3. **Invalid color space** — inputs are not provided in linear RGB and the + implementation does not perform conversion. +4. **Out-of-range values** — input color values are outside the expected range + and cannot be normalized. + +Error messages SHOULD include sufficient context for the caller to identify the +cause. Errors MUST NOT be silently ignored. + +--- + +## 11. Implementation Requirements + +**(Normative)** + +A conforming implementation MUST: + +- Perform all compositing arithmetic in linear RGB. +- Apply the equations in Section 4 exactly as specified. +- Aggregate per-channel alpha estimates deterministically. +- Clamp the final alpha value to [0.0, 1.0]. +- Document the aggregation policy and the value of `EPSILON`. + +A conforming implementation MUST NOT: + +- Use chroma-key extraction. +- Use machine-learning inference. +- Use RGB-distance heuristics as the primary reconstruction method. +- Modify the input images. + +--- + +## 12. Relationship to Other Specifications + +**(Normative)** + +- The input observations `C1` and `C2` are produced by the color space conversion + stage described in AlphaForge color science documentation. +- The output alpha channel is consumed by the foreground reconstruction stage + described in `foreground-reconstruction.md`. +- The validation stage described in `validation.md` MUST run before alpha + reconstruction. + +--- + +## 13. Informative Discussion + +**(Informative)** + +### 13.1 Rationale + +The two-observation approach removes the need to know the foreground color `F` +algebraically. By capturing the same foreground against two different known +backgrounds, the unknown foreground term cancels out when the observations are +subtracted. This is the only mathematically correct closed-form solution for `α` +given the stated assumptions. + +### 13.2 Why linear RGB is mandatory + +The Porter-Duff "over" operator is a linear interpolation. Gamma-encoded sRGB +values are not linear, so applying the compositing equation directly to them +would introduce systematic error. Converting to linear RGB preserves the physical +validity of the derivation. + +### 13.3 Channel aggregation + +The current implementation combines the valid per-channel estimates using the +unweighted mean: + +``` +α_agg = (α_r + α_g + α_b) / count(usable channels) +``` + +Other deterministic strategies (median, weighted average, robust estimators) may +be evaluated in the future as optional refinements. The normative requirement is +only that the chosen strategy be deterministic and documented. + +### 13.4 Background color neutrality + +This specification intentionally does not require pure white or pure black. The +background colors are configuration parameters. The current production workflow +uses white `(255, 255, 255)` and dark `(10, 10, 10)` in sRGB, which correspond to +different linear RGB values after gamma decoding. Future implementations MAY use +other pairs as long as they are substantially different and known. + +### 13.5 Benchmarking considerations + +Future benchmarks SHOULD evaluate: + +- Accuracy against synthetic ground-truth alpha mattes. +- Sensitivity to background color pairs. +- Sensitivity to channel aggregation strategies. +- Numerical stability near `α = 0` and `α = 1`. +- Performance per megapixel. + +### 13.6 References + +- Porter, T., & Duff, T. (1984). Compositing digital images. _ACM SIGGRAPH + Computer Graphics_, 18(3), 253–259. +- Smith, A. R., & Blinn, J. F. (1996). Blue screen matting. _ACM SIGGRAPH + Computer Graphics_, 30, 259–268. +- Wallace, B. A. (1981). Merging and transformation of raster images for cartoon + animation. _ACM SIGGRAPH Computer Graphics_, 15(3), 253–262. diff --git a/docs/specifications/cleanup.md b/docs/specifications/cleanup.md new file mode 100644 index 0000000..b1d643c --- /dev/null +++ b/docs/specifications/cleanup.md @@ -0,0 +1,331 @@ +# Cleanup Specification + +## AlphaForge Reconstruction Pipeline — Part 3 + +**Version:** 1.0.0 + +**Status:** Stable. This document defines normative requirements for the +AlphaForge cleanup stage. + +--- + +## 1. Abstract + +This document specifies the cleanup stage of the AlphaForge reconstruction +pipeline. Cleanup is an optional deterministic refinement stage that operates on +the reconstructed alpha channel and, optionally, the reconstructed foreground +color image. + +Cleanup MUST NOT invent information. It MUST preserve geometry and MUST be +deterministic. + +--- + +## 2. Conformance + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119. + +Sections explicitly marked **(Normative)** define mandatory behavior. + +Sections explicitly marked **(Informative)** provide rationale, examples, future +direction, and non-binding guidance. + +--- + +## 3. Scope + +**(Normative)** + +This specification applies to the AlphaForge cleanup stage only. + +The cleanup stage MAY: + +- Refine the alpha channel. +- Remove isolated noise artifacts. +- Apply morphological and filtering operations. + +The cleanup stage MUST NOT: + +- Invent or hallucinate image information. +- Change the geometry of the foreground in a non-reversible manner. +- Use machine-learning inference. +- Use non-deterministic algorithms. + +Cleanup is optional. An implementation MAY pass the reconstructed image through +cleanup unchanged if no cleanup policy is configured. + +--- + +## 4. Assumptions + +**(Normative)** + +### 4.1 Input validity + +The inputs to cleanup MUST be a valid reconstructed alpha channel and, optionally, +a valid reconstructed foreground image. The dimensions of all inputs MUST match. + +### 4.2 Linear RGB + +If cleanup operates on the foreground color image, it MUST operate on linear RGB +values. Operations that are not linear-RGB-aware MUST be documented as such. + +### 4.3 Deterministic configuration + +Every cleanup operation MUST be controlled by explicit configuration parameters. +The default values of these parameters are implementation-defined. + +--- + +## 5. Mandatory Cleanup Behavior + +**(Normative)** + +### 5.1 Determinism + +Every cleanup operation MUST be deterministic. Given identical inputs and +identical configuration, the output MUST be identical. + +### 5.2 Information preservation + +Cleanup MUST NOT invent information. Operations MUST only modify pixels based on +values present in the input alpha channel and, optionally, the input foreground +image. + +### 5.3 Geometry preservation + +Cleanup MUST preserve the overall geometry of the foreground. A cleanup +operation MAY remove small disconnected artifacts, but it MUST NOT merge or +sever connected structures that are larger than the configured artifact size. + +### 5.4 Alpha channel bounds + +After cleanup, every alpha value MUST remain in the range [0, 1]. + +### 5.5 Color channel bounds + +If cleanup operates on the foreground color image, every color channel MUST +remain in the range [0, 1] after clamping. + +--- + +## 6. Optional Cleanup Operations + +**(Normative)** + +The following operations are OPTIONAL. An implementation MAY support any subset +of them. If supported, each operation MUST be configurable and deterministic. + +### 6.1 Alpha thresholding + +Alpha thresholding converts near-transparent pixels to fully transparent and, +optionally, near-opaque pixels to fully opaque. + +A thresholding operation MUST be parameterized by at least: + +- A lower threshold `α_low`. +- An upper threshold `α_high`. + +The operation MUST satisfy: + +``` +if α < α_low then α_out = 0 +if α > α_high then α_out = 1 +otherwise α_out = α +``` + +Values of `α_low` and `α_high` MUST be documented by the implementation. + +### 6.2 Noise removal + +Noise removal eliminates isolated pixel artifacts in the alpha channel. + +A noise removal operation MUST be parameterized by a maximum artifact size in +pixels. Connected components smaller than this size MAY be removed. + +The connectivity model (e.g., 4-connectivity or 8-connectivity) MUST be +documented. + +### 6.3 Morphological opening + +Morphological opening removes small protrusions from the foreground shape. + +A morphological opening operation MUST be parameterized by a structuring element. +The shape and size of the structuring element MUST be documented. + +### 6.4 Morphological closing + +Morphological closing fills small holes in the foreground shape. + +A morphological closing operation MUST be parameterized by a structuring element. +The shape and size of the structuring element MUST be documented. + +### 6.5 Median filtering + +Median filtering reduces high-frequency noise in the alpha channel and, optionally, +in the foreground color image. + +A median filtering operation MUST be parameterized by a window size. The window size +MUST be a positive odd integer. The handling of edge pixels MUST be documented. + +--- + +## 7. Configurable Cleanup Pipeline + +**(Normative)** + +A cleanup pipeline is an ordered sequence of cleanup operations. Each operation +receives the output of the previous operation as its input. + +A pipeline configuration MUST specify: + +1. The ordered list of operations. +2. The parameters for each operation. +3. Whether the operation applies to the alpha channel, the foreground image, or + both. + +An implementation MUST execute the operations in the specified order. An +implementation MUST NOT silently reorder operations. + +--- + +## 8. Required Inputs + +**(Normative)** + +The cleanup stage MUST accept the following inputs: + +1. **Alpha channel** — a scalar value for every pixel, in the range [0, 1]. +2. **Foreground image** — a linear RGB color image. This input is OPTIONAL. +3. **Cleanup configuration** — an ordered list of cleanup operations and their + parameters. + +--- + +## 9. Required Outputs + +**(Normative)** + +The cleanup stage MUST produce: + +1. **Cleaned alpha channel** — a scalar value for every pixel, in the range [0, 1]. +2. **Cleaned foreground image** — a linear RGB color image. This output is + OPTIONAL and is only produced if a foreground image was provided. + +--- + +## 10. Deterministic Behavior + +**(Normative)** + +A conforming implementation MUST be deterministic: + +- For any fixed set of inputs and configuration, the output MUST be identical + across runs on the same implementation. +- The order of operations MUST be deterministic. +- The connectivity model, structuring element, and edge handling rules MUST be + documented. + +--- + +## 11. Error Handling + +**(Normative)** + +The cleanup stage MUST detect and report the following error conditions: + +1. **Dimension mismatch** — the alpha channel and the foreground image do not have + identical dimensions. +2. **Invalid alpha values** — input alpha values outside the range [0, 1]. +3. **Invalid configuration** — an operation is configured with contradictory or + out-of-range parameters. + +Error messages SHOULD include sufficient context for the caller. Errors MUST NOT +be silently ignored. + +--- + +## 12. Implementation Requirements + +**(Normative)** + +A conforming implementation MUST: + +- Execute cleanup operations deterministically. +- Preserve alpha and color values within [0, 1]. +- Document all configurable parameters and their defaults. +- Respect the order of operations specified in the pipeline configuration. + +A conforming implementation MUST NOT: + +- Use machine-learning inference. +- Invent image information. +- Apply cleanup operations that are not explicitly configured. + +--- + +## 13. Relationship to Other Specifications + +**(Normative)** + +- The input alpha channel and foreground image are produced by the alpha and + foreground reconstruction stages described in `alpha-reconstruction.md` and + `foreground-reconstruction.md`. +- The output of cleanup is consumed by the export stage. + +--- + +## 14. Informative Discussion + +**(Informative)** + +### 14.1 Rationale + +Cleanup is intentionally optional. The reconstruction stages already produce a +mathematically correct alpha channel and foreground color. Cleanup exists only to +remove artifacts introduced by quantization, sensor noise, or imperfect +background uniformity. + +### 14.2 Thresholding configuration + +Thresholding is controlled by explicit `α_low` and `α_high` values supplied by +the caller. The implementation documents these parameters and does not silently +apply a default threshold. + +### 14.3 Morphological operations + +Morphological opening and closing are useful for removing small artifacts while +preserving large-scale geometry. The choice of structuring element (e.g., disk, +square, cross) affects the result. A disk-shaped element is often preferred for +natural shapes because it is rotationally invariant. + +### 14.4 Median filtering + +Median filtering is edge-preserving and is well suited to alpha channels because +it does not blur hard edges. Common window sizes are 3x3 and 5x5. + +### 14.5 Connected-component cleanup + +Connected-component analysis can remove isolated single-pixel noise or small +clusters. The artifact size threshold should be chosen based on the expected +resolution and content. + +### 14.6 Future pipeline evolution + +Future versions of this specification MAY define: + +- A default cleanup pipeline. +- Pipeline presets for common content types. +- Performance benchmarks for cleanup operations. +- Additional deterministic filters such as bilateral filtering or guided + filtering. + +### 14.7 References + +- Serra, J. (1982). _Image Analysis and Mathematical Morphology_. Academic Press. +- Soille, P. (2003). _Morphological Image Analysis: Principles and Applications_. + Springer. +- Porter, T., & Duff, T. (1984). Compositing digital images. _ACM SIGGRAPH + Computer Graphics_, 18(3), 253–259. diff --git a/docs/specifications/foreground-reconstruction.md b/docs/specifications/foreground-reconstruction.md new file mode 100644 index 0000000..c3a84cb --- /dev/null +++ b/docs/specifications/foreground-reconstruction.md @@ -0,0 +1,408 @@ +# Foreground Reconstruction Specification + +## AlphaForge Reconstruction Pipeline — Part 2 + +**Version:** 1.0.0 + +**Status:** Stable. This document defines normative requirements for the +AlphaForge foreground reconstruction stage. + +--- + +## 1. Abstract + +This document specifies the foreground reconstruction stage of the AlphaForge +reconstruction pipeline. Foreground reconstruction recovers the original +foreground color of each pixel from an observed composite image, a known +background color, and the recovered alpha value. + +The algorithm is the inverse of the Porter-Duff "over" operator. It operates in +linear RGB color space and is deterministic. + +--- + +## 2. Conformance + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119. + +Sections explicitly marked **(Normative)** define mandatory behavior. + +Sections explicitly marked **(Informative)** provide rationale, examples, future +direction, and non-binding guidance. + +--- + +## 3. Scope + +**(Normative)** + +This specification applies to the AlphaForge foreground reconstruction stage only. + +The foreground reconstruction stage MUST: + +- Accept two aligned observed composite images, their corresponding known + background colors, and a scalar alpha channel. +- Treat both observations as equal inputs. +- Produce a reconstructed foreground color for every pixel. +- Operate in linear RGB color space. +- Be deterministic and invariant to observation order. + +The foreground reconstruction stage MUST NOT: + +- Invent or hallucinate foreground information. +- Use chroma-key extraction. +- Use machine-learning inference. +- Modify the input alpha channel. + +--- + +## 4. Mathematical Foundations + +**(Normative)** + +### 4.1 Color representation + +All color values are represented as three-component vectors in linear RGB space: + +``` +C = (C_r, C_g, C_b) +``` + +Each component is a real number in the range [0, 1]. + +The alpha value α is a scalar in the range [0, 1]. + +### 4.2 Physical compositing model + +The observed composite image is modeled by the Porter-Duff "over" operator: + +``` +C = αF + (1 - α)B +``` + +where: + +- `C` is the observed composite color. +- `F` is the foreground color. +- `B` is the known background color. +- `α` is the scalar opacity of the foreground. + +### 4.3 Foreground recovery equation + +Given `C`, `B`, and `α`, the foreground color `F` is recovered by solving the +compositing equation for `F`: + +``` +F = (C - (1 - α)B) / α +``` + +This equation MUST be applied per channel `c` ∈ {r, g, b}: + +``` +F_c = (C_c - (1 - α)B_c) / α +``` + +### 4.4 Symmetric foreground recovery + +AlphaForge receives two aligned observations of the same foreground: + +``` +C1 = αF + (1 - α)B1 +C2 = αF + (1 - α)B2 +``` + +Both observations MUST participate equally in foreground reconstruction. For each +pixel, a foreground estimate `F1` is recovered from observation `C1` and +background `B1`, and a foreground estimate `F2` is recovered from observation `C2` +and background `B2`: + +``` +F1 = (C1 - (1 - α)B1) / α +F2 = (C2 - (1 - α)B2) / α +``` + +The final output foreground is the symmetric average of the two estimates: + +``` +F_out = (F1 + F2) / 2 +``` + +Because both estimates are mathematically equal to `F`, the average is also +equal to `F`. The average reduces quantization bias and guarantees that the output +does not depend on the order in which the observations are provided. + +### 4.5 Domain restriction + +For pixels where `α < ALPHA_THRESHOLD`, the foreground recovery equation is +undefined or numerically unstable. An implementation MUST handle these pixels +explicitly as specified in Section 6.1. + +For pixels where `α ≥ ALPHA_THRESHOLD`, the foreground color is uniquely +determined by the equations in Section 4.4. + +--- + +## 5. Assumptions + +**(Normative)** + +### 5.1 Linear RGB input + +The observed composite images `C1` and `C2` and the background colors `B1` and +`B2` MUST be in linear RGB color space. The alpha channel `α` is a scalar and is +assumed to be linear. + +### 5.2 Consistent geometry + +The alpha channel and both composite images MUST have identical width and height. +Each pixel coordinate `(x, y)` corresponds to the same spatial location across all +inputs. + +### 5.3 Known background colors + +The background colors `B1` and `B2` MUST be the same colors that were used to +produce the observed composite images `C1` and `C2`. + +### 5.4 Valid alpha range + +The input alpha channel MUST contain values in the range [0, 1]. Values outside +this range MUST be treated as errors or clamped by a prior stage. + +### 5.5 Observation pairing + +The two observed composite images are the same source observations used for alpha +reconstruction. The corresponding background colors are the same `B1` and `B2` +that were supplied to the alpha reconstruction stage. + +### 5.6 Order invariance + +The foreground reconstruction stage MUST produce the same output regardless of +the order in which the two observations and their backgrounds are supplied. This +is a direct consequence of the symmetric averaging in Section 4.4. + +--- + +## 6. Numerical Stability + +**(Normative)** + +### 6.1 Near-zero alpha + +A mandatory `ALPHA_THRESHOLD` is REQUIRED to prevent division by a near-zero +alpha value that would amplify quantization noise: + +``` +ALPHA_THRESHOLD = 0.01 +``` + +For pixels where `α < ALPHA_THRESHOLD`, the implementation MUST NOT apply the +division `F_c = (C_c - (1 - α)B_c) / α`. Instead, the recovered foreground color +MUST default to the neutral value: + +``` +F_out = (0.0, 0.0, 0.0) +``` + +For pixels where `α ≥ ALPHA_THRESHOLD`, the foreground recovery equation MUST be +applied. + +The value of `ALPHA_THRESHOLD` is mandatory and MUST NOT be configurable. + +### 6.2 Clamping + +After foreground reconstruction, each color channel `F_c` MUST be strictly +clamped to the physical range [0.0, 1.0]: + +``` +F_c_out = clamp(F_c, 0.0, 1.0) +``` + +This clamping is required because numerical error and out-of-gamut intermediate +results can produce values outside the valid range. + +### 6.3 Floating-point precision + +All intermediate calculations SHOULD be performed using a floating-point +representation that preserves precision for the intended output bit depth. For +8-bit output, single-precision IEEE 754 floating-point arithmetic is sufficient. + +--- + +## 7. Required Inputs + +**(Normative)** + +The foreground reconstruction stage MUST accept the following inputs: + +1. **Observed composite image 1** — an image `C1` in linear RGB color space. +2. **Background color 1** — a linear RGB color value `B1` corresponding to the + background used to generate `C1`. +3. **Observed composite image 2** — an image `C2` in linear RGB color space. +4. **Background color 2** — a linear RGB color value `B2` corresponding to the + background used to generate `C2`. +5. **Alpha channel** — a scalar value `α` for every pixel, in the range [0, 1]. + +The implementation MAY accept additional configuration parameters (e.g., +output bit depth) provided that their behavior is documented. The value of +`ALPHA_THRESHOLD` and the fallback color are mandatory and MUST NOT be +configurable. + +--- + +## 8. Required Outputs + +**(Normative)** + +The foreground reconstruction stage MUST produce: + +1. **Reconstructed foreground image** — a linear RGB color value `F_out` for + every pixel. +2. **Alpha channel** — the same scalar alpha value `α` for every pixel, either + included in the output image or passed through to the next stage. + +The output foreground color `F_out` MUST be strictly clamped to [0.0, 1.0] per +channel. + +--- + +## 9. Deterministic Behavior + +**(Normative)** + +A conforming implementation MUST be deterministic: + +- For any fixed set of inputs, the output foreground image MUST be identical + across runs on the same implementation. +- The fallback color for near-zero alpha pixels MUST be deterministic. +- The order of operations MUST be deterministic. +- Floating-point operations MUST use the same precision and rounding mode on the + same platform. + +--- + +## 10. Error Handling + +**(Normative)** + +The foreground reconstruction stage MUST detect and report the following error +conditions: + +1. **Dimension mismatch** — the alpha channel and either composite image do not + have identical width and height. +2. **Invalid alpha values** — alpha values outside the range [0, 1]. +3. **Invalid color space** — inputs are not in linear RGB and the implementation + does not perform conversion. + +Error messages SHOULD include sufficient context for the caller to identify the +cause. Errors MUST NOT be silently ignored. + +--- + +## 11. Implementation Requirements + +**(Normative)** + +A conforming implementation MUST: + +- Apply the foreground recovery equations in Section 4.4 exactly. +- Handle near-zero alpha pixels deterministically. +- Clamp the output foreground color to [0.0, 1.0] per channel. +- Operate in linear RGB color space. +- Treat both observations as equal inputs. +- Produce identical output when the observations and their backgrounds are + swapped. +- Document the fallback color and the value of `ALPHA_THRESHOLD`. + +A conforming implementation MUST NOT: + +- Use chroma-key extraction. +- Use machine-learning inference. +- Use RGB-distance heuristics. +- Modify the input alpha channel. + +--- + +## 12. Relationship to Other Specifications + +**(Normative)** + +- The alpha channel is produced by the alpha reconstruction stage described in + `alpha-reconstruction.md`. +- The two observed composite images and their backgrounds are the same inputs + supplied to the alpha reconstruction stage. +- The output foreground image is consumed by the cleanup stage described in + `cleanup.md` and by the export stage. +- The validation stage described in `validation.md` MUST run before foreground + reconstruction. + +--- + +## 13. Informative Discussion + +**(Informative)** + +### 13.1 Rationale + +The foreground recovery equation is the algebraic inverse of the "over" +operator. Given a correct alpha value and a known background, it is the only +mathematically correct way to recover the original foreground color without +additional information. + +Because AlphaForge always has two observations available from the alpha +reconstruction stage, using both observations for foreground recovery is +natural. Averaging the two independent foreground estimates makes the result +symmetric with respect to observation order and reduces the impact of per- +observation quantization noise. + +### 13.2 Straight alpha vs. premultiplied alpha + +Alpha channels can be represented in two common forms: + +- **Straight alpha:** The color channels store the original foreground color `F`. + The composite is `C = αF + (1 - α)B`. +- **Premultiplied alpha:** The color channels store `αF`. The composite is + `C = αF + (1 - α)B`, but the stored color is already multiplied by alpha. + +This specification recovers the straight foreground color `F`. The equations in +Section 4.3 are defined for straight alpha. + +The final PNG export stage stores straight alpha, as described in +`png-export.md`. A future optional refinement MAY convert to premultiplied alpha +for storage or pipeline convenience, but the reconstruction core always produces +straight foreground color values. + +### 13.3 Near-zero alpha handling + +When `α < ALPHA_THRESHOLD`, the foreground recovery equation is numerically +unstable. A deterministic fallback is required to avoid undefined behavior or +division by zero. This specification mandates black `(0.0, 0.0, 0.0)` as the +fallback color to ensure reproducible output and to avoid propagating background +color artifacts into transparent regions. + +### 13.4 Color recovery in shadows and edges + +Because the alpha reconstruction stage is based on a physical model, anti-aliased +edges, soft shadows, and partial transparency are preserved as linear RGB and +alpha values. The foreground recovery stage then recovers the correct color for +these pixels, subject to the numerical limits of the input bit depth. + +### 13.5 Benchmarking considerations + +Future benchmarks SHOULD evaluate: + +- Color accuracy against synthetic ground-truth foregrounds. +- Behavior near `α = 0` and `α = 1`. +- Impact of the fallback color choice. +- Impact of the `ALPHA_THRESHOLD` value. +- Sensitivity to noise in the alpha channel. + +### 13.6 References + +- Porter, T., & Duff, T. (1984). Compositing digital images. _ACM SIGGRAPH + Computer Graphics_, 18(3), 253–259. +- Smith, A. R., & Blinn, J. F. (1996). Blue screen matting. _ACM SIGGRAPH + Computer Graphics_, 30, 259–268. +- Wallace, B. A. (1981). Merging and transformation of raster images for cartoon + animation. _ACM SIGGRAPH Computer Graphics_, 15(3), 253–262. diff --git a/docs/specifications/png-export.md b/docs/specifications/png-export.md new file mode 100644 index 0000000..1cabe0d --- /dev/null +++ b/docs/specifications/png-export.md @@ -0,0 +1,262 @@ +# PNG Export Specification + +## AlphaForge Reconstruction Pipeline — Part 4 + +**Version:** 1.0.0 + +**Status:** Stable. This document defines normative requirements for the +AlphaForge PNG export stage. + +--- + +## 1. Abstract + +This document specifies the PNG export stage of the AlphaForge reconstruction +pipeline. PNG export converts deterministic reconstructed data into a standard +RGBA8 PNG asset. + +The export stage is purely a conversion and encoding boundary. It does not +perform mathematical reconstruction, color guessing, or hidden processing. + +--- + +## 2. Conformance + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119. + +Sections explicitly marked **(Normative)** define mandatory behavior. + +Sections explicitly marked **(Informative)** provide rationale, examples, future +direction, and non-binding guidance. + +--- + +## 3. Scope + +**(Normative)** + +This specification applies to the AlphaForge PNG export stage only. + +The PNG export stage MUST: + +- Accept a reconstructed foreground image and a matching alpha channel. +- Convert the linear RGB foreground and linear alpha into an sRGB RGBA8 PNG + file. +- Use the existing color conversion boundary for sRGB encoding. +- Be deterministic. + +The PNG export stage MUST NOT: + +- Perform alpha or foreground reconstruction. +- Perform cleanup or post-processing. +- Guess or invent pixel values. +- Use machine-learning inference. +- Modify the input data. + +--- + +## 4. Input Requirements + +**(Normative)** + +### 4.1 ForegroundImageData + +The foreground image input MUST: + +- Contain exactly three channels. +- Represent linear RGB color values. +- Store values in a `Float32Array`. +- Contain values in the range `[0, 1]`. + +### 4.2 AlphaChannelData + +The alpha channel input MUST: + +- Contain exactly one channel. +- Store values in a `Float32Array`. +- Contain values in the range `[0, 1]`. + +### 4.3 Dimensions + +The foreground image and the alpha channel MUST have identical width and height. + +### 4.4 Output path + +The export stage MUST be provided with a destination path. The path MUST be a +non-empty string. + +--- + +## 5. Processing Pipeline + +**(Normative)** + +The PNG export stage MUST perform the following steps in order: + +``` +ForegroundImageData ++ +AlphaChannelData + + ↓ + +Create temporary Linear RGBA representation + + ↓ + +linearToSrgb() + + ↓ + +RGBA8 ImageData + + ↓ + +PNG encoding + + ↓ + +Output file +``` + +The temporary Linear RGBA representation MUST contain the red, green, blue, and +alpha values for each pixel in RGBA order. + +--- + +## 6. Color Requirements + +**(Normative)** + +### 6.1 Gamma conversion + +RGB conversion to sRGB MUST use the existing `linearToSrgb` function from the +color module. + +### 6.2 No duplicated color logic + +The export stage MUST NOT duplicate gamma conversion logic or implement a +separate sRGB encoding path. + +### 6.3 Alpha handling + +Alpha values MUST remain unchanged during color conversion. The alpha channel +MUST be passed through to the RGBA8 output without additional transformation, +clamping, or quantization beyond what is required for 8-bit storage. + +### 6.4 Output format + +The output file MUST be a standard RGBA8 PNG with straight alpha. + +--- + +## 7. Validation Requirements + +**(Normative)** + +The export stage MUST reject the following conditions: + +1. **Missing path** — the output path is empty or not a string. +2. **Dimension mismatch** — the foreground image and the alpha channel do not have + identical width and height. +3. **Invalid channel counts** — the foreground image does not have exactly three + channels, or the alpha channel does not have exactly one channel. +4. **NaN values** — any foreground or alpha value is `NaN`. +5. **Infinity values** — any foreground or alpha value is positive or negative + `Infinity`. +6. **RGB values outside `[0, 1]`** — any foreground color channel is outside the + range `[0, 1]`. +7. **Alpha values outside `[0, 1]`** — any alpha value is outside the range + `[0, 1]`. + +The input data MUST NOT be mutated during validation or export. + +--- + +## 8. Determinism Requirements + +**(Normative)** + +The PNG export stage MUST be deterministic: + +- The export stage MUST NOT use randomness. +- The export stage MUST NOT use hidden processing or implicit defaults. +- The same logical input MUST produce equivalent processing results. +- The export stage MUST preserve reproducibility. + +An absolute byte-level PNG hash across different Sharp or libvips versions is +NOT guaranteed. AlphaForge guarantees deterministic preprocessing and encoding +behavior within the supported encoder environment. + +--- + +## 9. Error Handling + +**(Normative)** + +The PNG export stage MUST report failures through an `ExportError` class. + +`ExportError` MUST: + +- Extend `Error`. +- Set its `name` property to the class name. +- Support an optional `cause` property. +- Support an optional `context` property if additional caller information is + available. + +Errors MUST NOT be silently ignored. + +--- + +## 10. Relationship to Other Specifications + +**(Normative)** + +- The input `ForegroundImageData` is produced by the foreground reconstruction + stage described in `foreground-reconstruction.md`. +- The input `AlphaChannelData` is produced by the alpha reconstruction stage + described in `alpha-reconstruction.md` or by the cleanup stage described in + `cleanup.md`. +- The color conversion is performed by the color science stage described in the + AlphaForge color science documentation. + +--- + +## 11. Informative Discussion + +**(Informative)** + +### 11.1 Rationale + +PNG export is the final deterministic boundary between AlphaForge's internal +linear RGB representations and standard production assets. Keeping the export +stage small and focused on conversion and encoding ensures that it cannot +accidentally introduce mathematical errors or non-deterministic adjustments. + +### 11.2 Why reuse `linearToSrgb` + +Reusing the existing color conversion function guarantees that every output PNG +shares the same gamma encoding as the rest of the library. Duplicating the +code would create a risk of inconsistent behavior and additional maintenance. + +### 11.3 Straight alpha + +PNG files conventionally store straight alpha. The reconstructed foreground +already contains the original color channels, and the alpha channel is stored +independently. This matches the expected output format for production pipelines. + +### 11.4 Encoder determinism + +Sharp delegates encoding to libvips. While the preprocessing steps performed by +AlphaForge are deterministic, the exact bytes produced by a PNG encoder may +vary across versions. The project therefore guarantees reproducible processing +behavior rather than a specific binary signature. + +### 11.5 References + +- PNG Specification, Third Edition, W3C Recommendation. +- IEC 61966-2-1:1999 — sRGB standard. +- Porter, T., & Duff, T. (1984). Compositing digital images. _ACM SIGGRAPH + Computer Graphics_, 18(3), 253–259. diff --git a/docs/specifications/validation.md b/docs/specifications/validation.md new file mode 100644 index 0000000..6b5843c --- /dev/null +++ b/docs/specifications/validation.md @@ -0,0 +1,311 @@ +# Validation Specification + +## AlphaForge Reconstruction Pipeline — Input Verification + +**Version:** 1.0.0 + +**Status:** Stable. This document defines normative requirements for the +AlphaForge validation stage. + +--- + +## 1. Abstract + +This document specifies the validation stage of the AlphaForge reconstruction +pipeline. Validation ensures that input images are safe to process before alpha +and foreground reconstruction begin. + +Validation MUST detect invalid inputs, report failures explicitly, and MUST NOT +modify the input images. + +--- + +## 2. Conformance + +The key words "MUST", "MUST NOT", "REQUIRED", "SHALL", "SHALL NOT", "SHOULD", +"SHOULD NOT", "RECOMMENDED", "MAY", and "OPTIONAL" in this document are to be +interpreted as described in RFC 2119. + +Sections explicitly marked **(Normative)** define mandatory behavior. + +Sections explicitly marked **(Informative)** provide rationale, examples, future +direction, and non-binding guidance. + +--- + +## 3. Scope + +**(Normative)** + +This specification applies to the AlphaForge validation stage only. + +The validation stage MUST: + +- Inspect input images for correctness. +- Report failures explicitly. +- Run before reconstruction. + +The validation stage MUST NOT: + +- Modify input images. +- Invent or hallucinate information. +- Use machine-learning inference. +- Produce non-deterministic output. + +--- + +## 4. Assumptions + +**(Normative)** + +Validation is the first stage of the pipeline. It assumes that the input images +are decodeable raster images and that the caller has provided the required number +of images. + +--- + +## 5. Current Validation + +**(Normative)** + +The following validation checks are mandatory for a conforming implementation. + +### 5.1 Metadata validation + +Each input image MUST be inspected for metadata integrity. + +A conforming implementation MUST validate at least: + +1. **Image dimensions** — the width and height of each image MUST be positive + integers. +2. **Channel count** — the image MUST have the expected number of color channels + (e.g., 3 for RGB, 4 for RGBA). +3. **Bit depth** — the bit depth MUST be supported by the implementation. + +If any metadata validation check fails, the implementation MUST report a +validation error and MUST NOT proceed to reconstruction. + +### 5.2 Dimension validation + +All input images that are intended to be processed together MUST have identical +dimensions. + +A conforming implementation MUST verify that: + +1. **Width equality** — all images have the same width in pixels. +2. **Height equality** — all images have the same height in pixels. + +If dimensions do not match, the implementation MUST report a validation error. + +### 5.3 Background color mismatch validation + +The implementation MAY compare the caller-provided background colors against the +colors measured from the image borders. + +If supported, the implementation MUST: + +- Sample the image border using a deterministic, documented strategy. +- Measure the mean color in linear RGB space. +- Compare the measured color against the declared color in linear RGB space. +- Use a configurable threshold to decide whether the mismatch is significant. +- Report the mismatch explicitly, including the declared color, the measured + color, and the distance between them. + +If supported, the implementation MUST NOT: + +- Infer or replace the caller-provided background colors. +- Modify the input images. +- Use machine-learning inference. +- Silently ignore a mismatch when the validation is configured to reject mismatches. + +The default threshold is an engineering default that may be refined by future +benchmarking. + +--- + +## 6. Future Validation + +**(Normative)** + +The following validation checks are OPTIONAL in this version of the +specification. A conforming implementation MAY support them. If supported, they +MUST be deterministic and documented. + +Background color mismatch validation is documented in Section 5.3 as a current +capability. + +### 6.1 Alignment verification + +Input images that are intended to be processed together MUST be geometrically +aligned. Alignment verification detects shifts, rotations, or scaling +differences between images. + +If alignment verification fails, the implementation MUST report a validation +error. + +### 6.2 Pixel difference visualization + +The implementation MAY generate a visual representation of pixel differences +between input images. This output is for debugging and diagnostics only. + +### 6.3 Difference map generation + +The implementation MAY generate a per-pixel difference map between input images. +The difference metric MUST be deterministic. The output MAY be used by the +debug stage or by automated quality reports. + +### 6.4 SSIM evaluation + +The implementation MAY compute the Structural Similarity Index Measure (SSIM) +between corresponding input images. + +If SSIM evaluation is supported, the implementation MUST document the window +size, the dynamic range, and the constants used in the computation. + +### 6.5 PSNR evaluation + +The implementation MAY compute the Peak Signal-to-Noise Ratio (PSNR) between +corresponding input images. + +If PSNR evaluation is supported, the implementation MUST document the dynamic +range used in the computation. + +### 6.6 Failure criteria + +When SSIM or PSNR evaluation is supported, the implementation MUST compare the +computed value against a threshold. The threshold is implementation-defined. + +If the value falls below the threshold, the implementation MUST report a +validation error. + +--- + +## 7. Required Inputs + +**(Normative)** + +The validation stage MUST accept: + +1. **Input images** — the set of images to be validated. +2. **Validation configuration** — the set of validation checks to perform. + +The validation configuration MUST specify which checks are enabled. + +--- + +## 8. Required Outputs + +**(Normative)** + +The validation stage MUST produce: + +1. **Validation result** — a structured result indicating whether validation + passed or failed. +2. **Validation issues** — a list of detected issues, each containing: + - A description of the issue. + - The severity of the issue. + - The location or context of the issue, when applicable. + +If validation fails, the implementation MUST NOT proceed to reconstruction. + +--- + +## 9. Deterministic Behavior + +**(Normative)** + +A conforming implementation MUST be deterministic: + +- For identical inputs and validation configuration, the validation result MUST + be identical across runs on the same implementation. +- The order of checks MUST be deterministic. +- Metrics such as SSIM and PSNR MUST be computed deterministically. + +--- + +## 10. Error Handling + +**(Normative)** + +Validation errors MUST be explicit and structured. Each error MUST include: + +1. A machine-readable error type. +2. A human-readable message. +3. The severity of the issue. +4. Sufficient context for the caller to locate the problem. + +Validation MUST NOT silently ignore failures. + +--- + +## 11. Implementation Requirements + +**(Normative)** + +A conforming implementation MUST: + +- Perform metadata validation and dimension validation. +- Report validation failures explicitly. +- Prevent reconstruction from running when validation fails. +- Document the supported validation checks. + +A conforming implementation MUST NOT: + +- Modify input images. +- Use machine-learning inference. +- Produce non-deterministic validation results. + +--- + +## 12. Relationship to Other Specifications + +**(Normative)** + +- Validation runs before the color space conversion and reconstruction stages. +- The validation output controls whether the pipeline continues to the alpha + reconstruction stage described in `alpha-reconstruction.md`. + +--- + +## 13. Informative Discussion + +**(Informative)** + +### 13.1 Rationale + +Validation is the gatekeeper of the pipeline. Reconstruction is mathematically +correct only when its assumptions are satisfied. Validation detects violations +early, before expensive or incorrect processing occurs. + +### 13.2 Thresholds + +Numerical thresholds for SSIM, PSNR, and alignment verification are intentionally +not defined in this specification. They will be established after empirical +benchmarking on representative datasets. + +### 13.3 Severity levels + +Validation issues MAY be classified by severity. A possible classification is: + +- **Error** — the pipeline cannot proceed. +- **Warning** — the pipeline can proceed, but the result may be unreliable. +- **Info** — informational only. + +### 13.4 Difference maps + +Difference maps are useful for debugging background consistency and alignment +issues. They can be stored as grayscale images or heat maps. They are not part of +the production output unless explicitly requested. + +### 13.5 SSIM and PSNR + +SSIM and PSNR are common image quality metrics. SSIM is perceptually motivated, +while PSNR is based on mean squared error. Neither is a perfect measure of +alignment or quality, but together they provide a useful automated check. + +### 13.6 References + +- Wang, Z., Bovik, A. C., Sheikh, H. R., & Simoncelli, E. P. (2004). Image + quality assessment: from error visibility to structural similarity. _IEEE + Transactions on Image Processing_, 13(4), 600–612. +- Pratt, W. K. (2001). _Digital Image Processing_. Wiley. diff --git a/eslint.config.js b/eslint.config.js new file mode 100644 index 0000000..9ebfcb0 --- /dev/null +++ b/eslint.config.js @@ -0,0 +1,33 @@ +import js from "@eslint/js"; +import tsParser from "@typescript-eslint/parser"; +import tsPlugin from "@typescript-eslint/eslint-plugin"; +import globals from "globals"; + +export default [ + { + ignores: ["dist/**", "node_modules/**", "coverage/**"], + }, + { + files: ["**/*.ts", "**/*.js"], + languageOptions: { + parser: tsParser, + parserOptions: { + ecmaVersion: "latest", + sourceType: "module", + project: "./tsconfig.eslint.json", + }, + globals: { + ...globals.node, + }, + }, + plugins: { + "@typescript-eslint": tsPlugin, + }, + rules: { + ...js.configs.recommended.rules, + ...tsPlugin.configs.recommended.rules, + "@typescript-eslint/no-unused-vars": ["error", { argsIgnorePattern: "^_" }], + "no-console": "warn", + }, + }, +]; diff --git a/examples/reconstruct-pipeline/.gitignore b/examples/reconstruct-pipeline/.gitignore new file mode 100644 index 0000000..84703d7 --- /dev/null +++ b/examples/reconstruct-pipeline/.gitignore @@ -0,0 +1,5 @@ +# Generated validation artifacts +*.png +*.jpg +*.jpeg +*.webp diff --git a/examples/reconstruct-pipeline/README.md b/examples/reconstruct-pipeline/README.md new file mode 100644 index 0000000..1836210 --- /dev/null +++ b/examples/reconstruct-pipeline/README.md @@ -0,0 +1,213 @@ +# AlphaForge Reconstruction Pipeline Validation + +This directory contains an **experimental** validation harness for the complete AlphaForge reconstruction pipeline. It is not a production CLI and does not add new functionality to the library. + +The harness validates the pipeline from raw observations to a transparent PNG output: + +``` +observation A +observation B + ↓ +loadImage() + ↓ +assertImagesValid() + ↓ +srgbToLinear() + ↓ +reconstructAlpha() + ↓ +reconstructForeground() + ↓ +cleanup() (optional) + ↓ +exportPng() + ↓ +transparent RGBA8 PNG +``` + +## Build requirement + +The validation workflow consumes the compiled public API from `dist/`. Build the project before running any validation script: + +```bash +pnpm build +``` + +Do not run the scripts against the TypeScript source directly; they import from `../../dist/index.js`. + +## Synthetic validation + +`generate-synthetic.mjs` creates controlled test assets: + +- `original-reference.png` — the ground-truth foreground with alpha. +- `observation-white.png` — the same foreground composited over white. +- `observation-black.png` — the same foreground composited over black. + +The foreground is composited in **linear RGB** so the reconstruction has a mathematically correct ground truth. + +### Generate the assets + +```bash +cd examples/reconstruct-pipeline +node generate-synthetic.mjs +``` + +### Reconstruct and validate + +```bash +node reconstruct.mjs \ + observation-white.png \ + observation-black.png \ + output.png \ + --reference original-reference.png \ + --debug +``` + +With `--reference`, the runner prints quantitative alpha reconstruction metrics: + +- Mean absolute error (MAE) +- Maximum error +- Percentage of pixels above error thresholds (1/255, 2/255, 5/255) + +With `--debug`, the runner writes additional diagnostic PNGs: + +- `output.debug-alpha-matte.png` +- `output.debug-foreground.png` + +## Real image validation + +You can also validate AlphaForge with your own AI-generated assets. + +### Required inputs + +1. Two images of the **same foreground** rendered against **two different, known background colors**. +2. The sRGB values of the two background colors. + +### Recommended capture conditions + +- Use a solid white background for one render and a solid near-black background for the other. +- Keep lighting, camera, and object pose identical between the two renders. +- Ensure backgrounds are uniform; avoid shadows, reflections, or background-dependent color effects. +- Export both images at the same pixel dimensions and without extra compression artifacts. + +### Run the validation + +```bash +node reconstruct.mjs \ + render-white.png \ + render-black.png \ + output.png \ + --bgA 255,255,255 \ + --bgB 10,10,10 \ + --debug +``` + +Adjust `--bgA` and `--bgB` to match the exact sRGB values of your backgrounds. + +### Optional cleanup + +```bash +node reconstruct.mjs \ + render-white.png \ + render-black.png \ + output.png \ + --bgA 255,255,255 \ + --bgB 10,10,10 \ + --threshold-low 0.01 \ + --threshold-high 0.99 \ + --noise-removal 3 \ + --debug +``` + +Use cleanup only when the output shows noise or small artifacts that are not part of the intended foreground. + +## Background validation + +AI-generated images often use background colors that differ slightly from the requested values. For example, a prompt asking for a black background may produce a dark gray background such as `[11,11,11]`, or a white background may become `[254,254,254]`. Feeding the requested colors into the reconstruction equations instead of the actual colors creates alpha errors and gray contamination. + +The harness can compare your declared background colors against the colors measured from the image borders and fail early when they do not match. + +### Enable validation + +```bash +node reconstruct.mjs \ + render-white.png \ + render-black.png \ + output.png \ + --bgA 255,255,255 \ + --bgB 10,10,10 \ + --validate-background \ + --debug +``` + +When `--validate-background` is provided, the harness samples the border of each observation in linear RGB space and checks that the measured color is within the configured threshold of the declared color. + +### Configure validation + +```bash +node reconstruct.mjs \ + render-white.png \ + render-black.png \ + output.png \ + --bgA 255,255,255 \ + --bgB 10,10,10 \ + --validate-background \ + --background-border-width 4 \ + --background-threshold 0.05 \ + --debug +``` + +### Catching a mismatch + +If you declare pure black but the image actually uses a dark gray background, validation fails before reconstruction: + +```bash +node reconstruct.mjs \ + render-white.png \ + render-dark-gray.png \ + output.png \ + --bgA 255,255,255 \ + --bgB 0,0,0 \ + --validate-background +``` + +This produces a `BackgroundMismatchError` for the dark observation and explains that the declared color does not match the measured border color. To proceed, measure the actual background color from the image border and pass it with `--bgB`. + +When `--validate-background` is omitted, the harness behaves exactly as before and runs reconstruction with the declared colors. + +## Expected results + +### Synthetic case + +- Background regions should become fully transparent. +- Opaque foreground regions should remain opaque. +- Semi-transparent edges should preserve smooth gradients. +- Quantitative metrics should be very small (MAE close to zero), limited mainly by 8-bit quantization. + +### Real case + +- Background regions should become transparent. +- Foreground edges should remain anti-aliased. +- Color contamination from the background should be removed. +- Perfect recovery is not expected if the input does not match the linear compositing model or if backgrounds are not perfectly uniform. + +## What to inspect + +Open the output PNG in an image viewer that supports transparency or in a tool like GIMP. Check: + +1. **Transparency correctness** — background areas are transparent, not gray or colored. +2. **Color correctness** — the foreground shows its original color without obvious background tint. +3. **Edge quality** — difficult regions (hair, leaves, thin structures, semi-transparent areas) retain detail. +4. **Cleanup behavior** — noise removal and morphology do not eat into real foreground edges. + +## Limitations + +- AlphaForge requires **two controlled observations** with known backgrounds. It is not a single-image background remover. +- The reconstruction assumes the foreground and background are combined with the Porter-Duff "over" operator in linear RGB. +- Real assets with reflections, translucency, shadows, or background-dependent lighting effects may not reconstruct perfectly. +- Geometry must be pixel-aligned between the two observations. +- Background colors must be substantially different in at least one channel. + +## Scope + +This validation harness is intentionally isolated in `examples/reconstruct-pipeline/`. It does not modify `src/`, the public API, ADRs, architecture documentation, or milestone tracking. diff --git a/examples/reconstruct-pipeline/generate-synthetic.mjs b/examples/reconstruct-pipeline/generate-synthetic.mjs new file mode 100644 index 0000000..6dc2959 --- /dev/null +++ b/examples/reconstruct-pipeline/generate-synthetic.mjs @@ -0,0 +1,217 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import sharp from "sharp"; +import { linearToSrgb, loadImage, srgbToLinear } from "../../dist/index.js"; + +/** + * Synthetic test asset generator for the AlphaForge reconstruction pipeline. + * + * Creates a controlled RGBA reference image and two observations of the same + * foreground over white and black backgrounds. The foreground is composited in + * linear RGB so the AlphaForge reconstruction has a mathematically correct + * ground truth to compare against. + * + * Outputs: + * - original-reference.png + * - observation-white.png + * - observation-black.png + * + * Usage: + * pnpm build + * node examples/reconstruct-pipeline/generate-synthetic.mjs + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const OUTPUT_DIR = __dirname; +const WIDTH = 512; +const HEIGHT = 512; + +const WHITE = [255, 255, 255]; +const BLACK = [0, 0, 0]; + +const COLORS = { + largeCircle: [220, 40, 60], + smallCircle: [40, 80, 220], + rectangle: [60, 180, 60], + gradientRect: [180, 60, 180], + default: [0, 0, 0], +}; + +function clamp(value, min, max) { + return Math.max(min, Math.min(max, value)); +} + +function smoothstep(edge0, edge1, value) { + const t = clamp((value - edge0) / (edge1 - edge0), 0, 1); + return t * t * (3 - 2 * t); +} + +function largeCircleAlpha(x, y) { + const cx = 180; + const cy = 220; + const innerRadius = 140; + const outerRadius = 170; + const distance = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); + return 1 - smoothstep(innerRadius, outerRadius, distance); +} + +function smallCircleAlpha(x, y) { + const cx = 350; + const cy = 350; + const innerRadius = 20; + const outerRadius = 25; + const distance = Math.sqrt((x - cx) ** 2 + (y - cy) ** 2); + return 1 - smoothstep(innerRadius, outerRadius, distance); +} + +function rectangleAlpha(x, y) { + if (x >= 80 && x < 200 && y >= 360 && y < 420) { + return 1; + } + return 0; +} + +function gradientRectAlpha(x, y) { + if (x < 260 || x >= 400 || y < 100 || y >= 200) { + return 0; + } + return (x - 260) / (400 - 260); +} + +function computeAlpha(x, y) { + return Math.max( + largeCircleAlpha(x, y), + smallCircleAlpha(x, y), + rectangleAlpha(x, y), + gradientRectAlpha(x, y), + ); +} + +function computeForegroundColor(x, y) { + if (smallCircleAlpha(x, y) > 0) { + return COLORS.smallCircle; + } + if (gradientRectAlpha(x, y) > 0) { + return COLORS.gradientRect; + } + if (rectangleAlpha(x, y) > 0) { + return COLORS.rectangle; + } + if (largeCircleAlpha(x, y) > 0) { + return COLORS.largeCircle; + } + return COLORS.default; +} + +function createReferenceImage() { + const data = new Uint8Array(WIDTH * HEIGHT * 4); + + for (let y = 0; y < HEIGHT; y += 1) { + for (let x = 0; x < WIDTH; x += 1) { + const alpha = computeAlpha(x, y); + const [r, g, b] = computeForegroundColor(x, y); + const index = (y * WIDTH + x) * 4; + data[index] = r; + data[index + 1] = g; + data[index + 2] = b; + data[index + 3] = Math.round(alpha * 255); + } + } + + return data; +} + +function srgbToLinearColor(srgb) { + const imageData = { + width: 1, + height: 1, + channels: 4, + format: "rgba8", + data: new Uint8Array([srgb[0], srgb[1], srgb[2], 255]), + path: "", + }; + const linear = srgbToLinear(imageData); + return [linear.data[0], linear.data[1], linear.data[2]]; +} + +function createObservation(backgroundSrgb, referenceLinear) { + const backgroundLinear = srgbToLinearColor(backgroundSrgb); + const linearData = new Float32Array(WIDTH * HEIGHT * 4); + + for (let i = 0; i < WIDTH * HEIGHT; i += 1) { + const alpha = referenceLinear.data[i * 4 + 3]; + const foregroundRed = referenceLinear.data[i * 4]; + const foregroundGreen = referenceLinear.data[i * 4 + 1]; + const foregroundBlue = referenceLinear.data[i * 4 + 2]; + + linearData[i * 4] = alpha * foregroundRed + (1 - alpha) * backgroundLinear[0]; + linearData[i * 4 + 1] = alpha * foregroundGreen + (1 - alpha) * backgroundLinear[1]; + linearData[i * 4 + 2] = alpha * foregroundBlue + (1 - alpha) * backgroundLinear[2]; + linearData[i * 4 + 3] = 1; + } + + return { + width: WIDTH, + height: HEIGHT, + channels: 4, + format: "linear-rgba8", + data: linearData, + path: "", + }; +} + +async function writePng(imageData, filename) { + const path = join(OUTPUT_DIR, filename); + await sharp(imageData.data, { + raw: { + width: imageData.width, + height: imageData.height, + channels: imageData.channels, + }, + }) + .png() + .toFile(path); + return path; +} + +async function main() { + await mkdir(OUTPUT_DIR, { recursive: true }); + + console.log(`Generating ${WIDTH}x${HEIGHT} synthetic test assets...`); + + const referencePath = await writePng( + { + width: WIDTH, + height: HEIGHT, + channels: 4, + data: createReferenceImage(), + }, + "original-reference.png", + ); + console.log(`Wrote reference: ${referencePath}`); + + const referenceImage = await loadImage(referencePath); + const referenceLinear = srgbToLinear(referenceImage); + + const whiteObservation = createObservation(WHITE, referenceLinear); + const whiteObservationPath = await writePng( + linearToSrgb(whiteObservation), + "observation-white.png", + ); + console.log(`Wrote observation: ${whiteObservationPath}`); + + const blackObservation = createObservation(BLACK, referenceLinear); + const blackObservationPath = await writePng( + linearToSrgb(blackObservation), + "observation-black.png", + ); + console.log(`Wrote observation: ${blackObservationPath}`); + + console.log("Synthetic test assets generated successfully."); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/examples/reconstruct-pipeline/reconstruct.mjs b/examples/reconstruct-pipeline/reconstruct.mjs new file mode 100644 index 0000000..5d963d6 --- /dev/null +++ b/examples/reconstruct-pipeline/reconstruct.mjs @@ -0,0 +1,393 @@ +import { mkdir } from "node:fs/promises"; +import { dirname, join } from "node:path"; +import { fileURLToPath } from "node:url"; +import { + assertBackgroundColorsValid, + assertImagesValid, + cleanup, + exportPng, + linearToSrgb, + loadImage, + reconstructAlpha, + reconstructForeground, + srgbToLinear, +} from "../../dist/index.js"; + +/** + * Experimental validation runner for the AlphaForge reconstruction pipeline. + * + * Reads two observations of the same foreground over known backgrounds, runs the + * full reconstruction, cleanup, and PNG export pipeline, and reports the result. + * Optionally compares the reconstructed alpha against a reference image and + * writes debug artifacts. + * + * Usage: + * pnpm build + * node examples/reconstruct-pipeline/reconstruct.mjs \ + * observation-white.png observation-black.png output.png \ + * --reference original-reference.png --debug + */ + +const __dirname = dirname(fileURLToPath(import.meta.url)); +const DEFAULT_WHITE = [255, 255, 255]; +const DEFAULT_BLACK = [0, 0, 0]; + +function parseSrgb(value) { + const parts = value.split(",").map(Number); + if (parts.length !== 3 || parts.some(Number.isNaN)) { + throw new Error(`Invalid sRGB color "${value}". Expected r,g,b with 0-255 values.`); + } + return parts; +} + +function parseStructuringElement(value) { + const [shape, radiusText] = value.split(","); + const radius = Number(radiusText); + const validShapes = ["square", "disk", "cross"]; + + if (!validShapes.includes(shape)) { + throw new Error(`Invalid shape "${shape}". Expected one of ${validShapes.join(", ")}.`); + } + if (!Number.isInteger(radius) || radius < 0) { + throw new Error(`Invalid radius "${radiusText}". Expected a non-negative integer.`); + } + + return { shape, radius }; +} + +function parsePositiveInteger(value) { + const number = Number(value); + if (!Number.isInteger(number) || number <= 0) { + throw new Error(`Invalid value "${value}". Expected a positive integer.`); + } + return number; +} + +function parsePositiveNumber(value) { + const number = Number(value); + if (Number.isNaN(number) || number <= 0) { + throw new Error(`Invalid value "${value}". Expected a positive number.`); + } + return number; +} + +function parseArgs(argv) { + const positional = []; + const flags = {}; + + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + + if (arg === "--bgA") { + flags.bgA = parseSrgb(argv[index + 1]); + index += 1; + } else if (arg === "--bgB") { + flags.bgB = parseSrgb(argv[index + 1]); + index += 1; + } else if (arg === "--reference") { + flags.reference = argv[index + 1]; + index += 1; + } else if (arg === "--debug") { + flags.debug = true; + } else if (arg === "--threshold-low") { + flags.thresholdLow = Number(argv[index + 1]); + index += 1; + } else if (arg === "--threshold-high") { + flags.thresholdHigh = Number(argv[index + 1]); + index += 1; + } else if (arg === "--noise-removal") { + flags.noiseRemoval = Number(argv[index + 1]); + index += 1; + } else if (arg === "--morphology-open") { + flags.morphologyOpen = parseStructuringElement(argv[index + 1]); + index += 1; + } else if (arg === "--morphology-close") { + flags.morphologyClose = parseStructuringElement(argv[index + 1]); + index += 1; + } else if (arg === "--validate-background") { + flags.validateBackground = true; + } else if (arg === "--background-border-width") { + flags.backgroundBorderWidth = parsePositiveInteger(argv[index + 1]); + index += 1; + } else if (arg === "--background-threshold") { + flags.backgroundThreshold = parsePositiveNumber(argv[index + 1]); + index += 1; + } else if (arg.startsWith("--")) { + throw new Error(`Unknown flag: ${arg}`); + } else { + positional.push(arg); + } + } + + return { positional, flags }; +} + +function srgbToLinearColor(srgb) { + const imageData = { + width: 1, + height: 1, + channels: 4, + format: "rgba8", + data: new Uint8Array([srgb[0], srgb[1], srgb[2], 255]), + path: "", + }; + const linear = srgbToLinear(imageData); + return [linear.data[0], linear.data[1], linear.data[2]]; +} + +function buildCleanupOptions(alpha, foreground, flags) { + const options = { alpha, foreground }; + + if (flags.thresholdLow !== undefined && flags.thresholdHigh !== undefined) { + options.threshold = { + alphaLow: flags.thresholdLow, + alphaHigh: flags.thresholdHigh, + }; + } + + if (flags.noiseRemoval !== undefined) { + options.noiseRemoval = { + maxArtifactSize: flags.noiseRemoval, + connectivity: 4, + }; + } + + if (flags.morphologyOpen || flags.morphologyClose) { + options.morphology = {}; + if (flags.morphologyOpen) { + options.morphology.opening = flags.morphologyOpen; + } + if (flags.morphologyClose) { + options.morphology.closing = flags.morphologyClose; + } + } + + return options; +} + +function shouldCleanup(flags) { + return ( + flags.thresholdLow !== undefined || + flags.noiseRemoval !== undefined || + flags.morphologyOpen !== undefined || + flags.morphologyClose !== undefined + ); +} + +async function writeAlphaMatte(alpha, path) { + const pixelCount = alpha.width * alpha.height; + const foregroundData = new Float32Array(pixelCount * 3); + + for (let pixel = 0; pixel < pixelCount; pixel += 1) { + const value = alpha.data[pixel]; + foregroundData[pixel * 3] = value; + foregroundData[pixel * 3 + 1] = value; + foregroundData[pixel * 3 + 2] = value; + } + + const foreground = { + width: alpha.width, + height: alpha.height, + channels: 3, + format: "linear-rgb", + data: foregroundData, + }; + const opaqueAlpha = { + width: alpha.width, + height: alpha.height, + channels: 1, + format: "alpha", + data: new Float32Array(pixelCount).fill(1), + }; + + await exportPng({ foreground, alpha: opaqueAlpha, path }); +} + +async function writeOpaqueForeground(foreground, path) { + const pixelCount = foreground.width * foreground.height; + const opaqueAlpha = { + width: foreground.width, + height: foreground.height, + channels: 1, + format: "alpha", + data: new Float32Array(pixelCount).fill(1), + }; + + await exportPng({ foreground, alpha: opaqueAlpha, path }); +} + +async function compareAlpha(outputPath, referencePath) { + const outputImage = await loadImage(outputPath); + const referenceImage = await loadImage(referencePath); + + if (outputImage.width !== referenceImage.width || outputImage.height !== referenceImage.height) { + throw new Error( + `Output and reference dimensions do not match: ` + + `output ${outputImage.width}x${outputImage.height}, ` + + `reference ${referenceImage.width}x${referenceImage.height}.`, + ); + } + + const pixelCount = outputImage.width * outputImage.height; + let totalError = 0; + let maxError = 0; + let above1 = 0; + let above2 = 0; + let above5 = 0; + + for (let pixel = 0; pixel < pixelCount; pixel += 1) { + const outputAlpha = outputImage.data[pixel * 4 + 3]; + const referenceAlpha = referenceImage.data[pixel * 4 + 3]; + const error = Math.abs(outputAlpha - referenceAlpha) / 255; + + totalError += error; + maxError = Math.max(maxError, error); + + if (error > 1 / 255) { + above1 += 1; + } + if (error > 2 / 255) { + above2 += 1; + } + if (error > 5 / 255) { + above5 += 1; + } + } + + const mae = totalError / pixelCount; + + return { + mae, + maxError, + threshold1Percentage: (above1 / pixelCount) * 100, + threshold2Percentage: (above2 / pixelCount) * 100, + threshold5Percentage: (above5 / pixelCount) * 100, + pixelCount, + }; +} + +async function main() { + const { positional, flags } = parseArgs(process.argv.slice(2)); + + if (positional.length !== 3) { + console.error("Usage: node reconstruct.mjs [options]"); + console.error(""); + console.error("Options:"); + console.error( + " --bgA r,g,b Background color for observation A (default: 255,255,255)", + ); + console.error( + " --bgB r,g,b Background color for observation B (default: 0,0,0)", + ); + console.error(" --reference path.png Reference image for alpha quality comparison"); + console.error(" --debug Write debug artifacts"); + console.error(" --threshold-low value Alpha threshold low (requires --threshold-high)"); + console.error(" --threshold-high value Alpha threshold high (requires --threshold-low)"); + console.error(" --noise-removal size Connected-component noise removal size"); + console.error(" --morphology-open shape,r Morphological opening (shape: square|disk|cross)"); + console.error(" --morphology-close shape,r Morphological closing (shape: square|disk|cross)"); + console.error(" --validate-background Enable background mismatch validation"); + console.error(" --background-border-width Border width for background sampling"); + console.error(" --background-threshold Linear RGB distance threshold for mismatch"); + process.exit(1); + } + + const [observationAPath, observationBPath, outputPath] = positional; + const bgA = flags.bgA ?? DEFAULT_WHITE; + const bgB = flags.bgB ?? DEFAULT_BLACK; + + console.log("Loading observations..."); + const observationA = await loadImage(observationAPath); + const observationB = await loadImage(observationBPath); + + console.log("Validating observations..."); + assertImagesValid(observationA, observationB); + + console.log("Converting declared backgrounds to linear RGB..."); + const backgroundA = srgbToLinearColor(bgA); + const backgroundB = srgbToLinearColor(bgB); + + if (flags.validateBackground) { + console.log("Validating background colors..."); + assertBackgroundColorsValid({ + imageA: observationA, + imageB: observationB, + backgroundA, + backgroundB, + borderWidth: flags.backgroundBorderWidth, + threshold: flags.backgroundThreshold, + }); + } + + console.log("Converting to linear RGB..."); + const linearA = srgbToLinear(observationA); + const linearB = srgbToLinear(observationB); + + console.log("Reconstructing alpha..."); + const alpha = reconstructAlpha({ + input1: { observation: linearA, background: backgroundA }, + input2: { observation: linearB, background: backgroundB }, + }); + + console.log("Reconstructing foreground..."); + let foreground = reconstructForeground({ + inputs: [ + { observation: linearA, background: backgroundA }, + { observation: linearB, background: backgroundB }, + ], + alpha, + }); + + let finalAlpha = alpha; + + if (shouldCleanup(flags)) { + console.log("Running cleanup..."); + const cleanupOptions = buildCleanupOptions(alpha, foreground, flags); + const cleanupResult = cleanup(cleanupOptions); + finalAlpha = cleanupResult.alpha; + foreground = cleanupResult.foreground ?? foreground; + } + + console.log("Exporting PNG..."); + await mkdir(dirname(outputPath), { recursive: true }); + const result = await exportPng({ + foreground, + alpha: finalAlpha, + path: outputPath, + }); + + console.log(`Wrote transparent PNG: ${result.path}`); + console.log(`File size: ${result.bytes} bytes`); + + if (flags.debug) { + const debugBase = outputPath.replace(/\.png$/iu, ""); + const alphaMattePath = `${debugBase}.debug-alpha-matte.png`; + const foregroundPath = `${debugBase}.debug-foreground.png`; + + console.log("Writing debug artifacts..."); + await writeAlphaMatte(finalAlpha, alphaMattePath); + await writeOpaqueForeground(foreground, foregroundPath); + console.log(`Wrote alpha matte: ${alphaMattePath}`); + console.log(`Wrote opaque foreground: ${foregroundPath}`); + } + + if (flags.reference) { + console.log("Comparing reconstructed alpha against reference..."); + const metrics = await compareAlpha(outputPath, flags.reference); + console.log(""); + console.log("Alpha reconstruction metrics:"); + console.log(` Pixel count: ${metrics.pixelCount}`); + console.log(` Mean absolute error: ${metrics.mae.toExponential(4)}`); + console.log(` Maximum error: ${metrics.maxError.toExponential(4)}`); + console.log(` Pixels > 1/255 error: ${metrics.threshold1Percentage.toFixed(2)}%`); + console.log(` Pixels > 2/255 error: ${metrics.threshold2Percentage.toFixed(2)}%`); + console.log(` Pixels > 5/255 error: ${metrics.threshold5Percentage.toFixed(2)}%`); + } + + console.log("Validation run complete."); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +}); diff --git a/package.json b/package.json new file mode 100644 index 0000000..60b7abb --- /dev/null +++ b/package.json @@ -0,0 +1,76 @@ +{ + "name": "alphaforge", + "version": "0.9.0", + "description": "Deterministic post-processing for AI-generated production assets", + "type": "module", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "exports": { + ".": { + "types": "./dist/index.d.ts", + "default": "./dist/index.js" + }, + "./package.json": "./package.json" + }, + "files": [ + "dist", + "README.md", + "LICENSE", + "CHANGELOG.md" + ], + "sideEffects": false, + "scripts": { + "build": "tsc", + "typecheck": "tsc --noEmit", + "test": "vitest run", + "test:watch": "vitest", + "lint": "eslint .", + "lint:fix": "eslint . --fix", + "format": "prettier --write .", + "format:check": "prettier --check .", + "prepublishOnly": "pnpm run build && pnpm run typecheck && pnpm run test" + }, + "keywords": [ + "alpha", + "transparency", + "image-processing", + "ai", + "production", + "deterministic", + "png", + "matte" + ], + "author": { + "name": "Mauricio Cabrera", + "email": "hello@maucabrera.dev", + "url": "https://maucabrera.dev" + }, + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/maucabreradev/alphaforge.git" + }, + "homepage": "https://github.com/maucabreradev/alphaforge#readme", + "bugs": { + "url": "https://github.com/maucabreradev/alphaforge/issues" + }, + "engines": { + "node": ">=20" + }, + "packageManager": "pnpm@9.0.0", + "devDependencies": { + "@eslint/js": "^9.39.5", + "@types/node": "^22.13.0", + "@typescript-eslint/eslint-plugin": "^8.24.0", + "@typescript-eslint/parser": "^8.24.0", + "eslint": "^9.21.0", + "fast-check": "^4.9.0", + "globals": "^16.0.0", + "prettier": "^3.5.0", + "typescript": "^5.7.0", + "vitest": "^3.0.0" + }, + "dependencies": { + "sharp": "^0.35.3" + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml new file mode 100644 index 0000000..6749d97 --- /dev/null +++ b/pnpm-lock.yaml @@ -0,0 +1,2957 @@ +lockfileVersion: "9.0" + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +importers: + .: + dependencies: + sharp: + specifier: ^0.35.3 + version: 0.35.3(@types/node@22.20.1) + devDependencies: + "@eslint/js": + specifier: ^9.39.5 + version: 9.39.5 + "@types/node": + specifier: ^22.13.0 + version: 22.20.1 + "@typescript-eslint/eslint-plugin": + specifier: ^8.24.0 + version: 8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/parser": + specifier: ^8.24.0 + version: 8.65.0(eslint@9.39.5)(typescript@5.9.3) + eslint: + specifier: ^9.21.0 + version: 9.39.5 + fast-check: + specifier: ^4.9.0 + version: 4.9.0 + globals: + specifier: ^16.0.0 + version: 16.5.0 + prettier: + specifier: ^3.5.0 + version: 3.9.6 + typescript: + specifier: ^5.7.0 + version: 5.9.3 + vitest: + specifier: ^3.0.0 + version: 3.2.7(@types/node@22.20.1) + +packages: + "@emnapi/runtime@1.11.3": + resolution: + { + integrity: sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==, + } + + "@esbuild/aix-ppc64@0.28.1": + resolution: + { + integrity: sha512-Svl7tq8k/08+p6CXPpRjQ1fKX+1odH/BQbb48fV6fj3CWHhsoIOoY87w1oHXm0qEpkIK3ZfVgp0hed3XBXzXMQ==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [aix] + + "@esbuild/android-arm64@0.28.1": + resolution: + { + integrity: sha512-34EGEbCIAgosYz6goLcopX6Mo7NyGv9tfwEM2/7Ce2VcVRk568iSvniGWcUXIy7wEDR1wzolcxcriFVrWYcwBg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [android] + + "@esbuild/android-arm@0.28.1": + resolution: + { + integrity: sha512-0k2F129Xdio1TdJfzJ8sy1Q47vUD2NnwdhiAf7drUN1EBTfPf4hsFCtmMgu/6m8JSzsBrlmVjudMBQqOfG8usQ==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [android] + + "@esbuild/android-x64@0.28.1": + resolution: + { + integrity: sha512-dbwY7ltSMDWsRatcRpCnES4F+im88OCUgGZjy52shC7GqHRE/cYlxNbB4Z4UpJswpcc4Qxd2oE/ufM0p61IKng==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [android] + + "@esbuild/darwin-arm64@0.28.1": + resolution: + { + integrity: sha512-TZbWkQY7kvTAXbXUT7uVACR5cMHsDiSz9z7ZKAX/RTq/WJEk3QyRr0wZpNhBDX+/0CtdqUIJlOiodQcta6tY3Q==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [darwin] + + "@esbuild/darwin-x64@0.28.1": + resolution: + { + integrity: sha512-zfdzgK9ACBNZLI/CyHTOx81SyNbM6YXn7rxSgX97VjyiPl9W1i4Ka4fgKECEoFCKGpvBj5qArWIGgQjOwkgskQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [darwin] + + "@esbuild/freebsd-arm64@0.28.1": + resolution: + { + integrity: sha512-wG2EA8ENdEI0qhkSZMjfqrdY+ziCYCPMmtZjjIwOmXFjmyzEHn+UUxk5of+SYsjtfs3VpnlC7QLzSI5hY/rOAw==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [freebsd] + + "@esbuild/freebsd-x64@0.28.1": + resolution: + { + integrity: sha512-i7dZ9vQgnvSCzi/rYCXNgtF/U+eKZNJBzu3eTQbRgHnM7tNSizLOkRFAl3qzVc/Op/u5YkHHa4pf/3DOYHthLQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [freebsd] + + "@esbuild/linux-arm64@0.28.1": + resolution: + { + integrity: sha512-yHs+0uc8+nvEAfAfxrWQKK5peSNzBc4PegcMO0EJ2hT71uA7vB8Ihg2e77R2P7SG5uYjPbHlLLmve4LLLRCf0g==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [linux] + + "@esbuild/linux-arm@0.28.1": + resolution: + { + integrity: sha512-qVXBOHQS+d5Y722GwJzJUtOLlX7km3CraOaGormF1pDtPd2C/l1SHRPgjLunLGe51Sh5YYWKMFDyV4SxgMQYTQ==, + } + engines: { node: ">=18" } + cpu: [arm] + os: [linux] + + "@esbuild/linux-ia32@0.28.1": + resolution: + { + integrity: sha512-d1z4ZuP0ajrfz/FhGT4vv278rX8KnPPJx8i5+AtK7TYbx9Le9F1hyzurZpkEyjkGa9dUGhQow4C1NmeGvqxN2w==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [linux] + + "@esbuild/linux-loong64@0.28.1": + resolution: + { + integrity: sha512-M5sRjUVZrkm1OAPR3dlOYzNmN+loZKGVi1VUQGrwuqLcbR6qeAz+famMhjASeH3YVKvZz+zT1jlh/keC3Rj/lg==, + } + engines: { node: ">=18" } + cpu: [loong64] + os: [linux] + + "@esbuild/linux-mips64el@0.28.1": + resolution: + { + integrity: sha512-mRObBZeHh2OxcBFPWE/FjylkRgZdYuiTR3vaTozquCGOH14iP9oN4x4Ge81CoIDYQrXmIxpFumJBu5MtZpnQJQ==, + } + engines: { node: ">=18" } + cpu: [mips64el] + os: [linux] + + "@esbuild/linux-ppc64@0.28.1": + resolution: + { + integrity: sha512-slScBsMAb3GFDcdrCgLwZtPYRoH2H/youv10QiZyRjmsP48fznoveWytSgCI/R0ZcUgpc0ZhIUEx6LHts8yrfQ==, + } + engines: { node: ">=18" } + cpu: [ppc64] + os: [linux] + + "@esbuild/linux-riscv64@0.28.1": + resolution: + { + integrity: sha512-kw0owk1o0GFETUJyW0jc0G4Yzs0BHZn0JDZ8JRT088vjJYX777BAs1fDGxAC+q831qOs2DTC96mNsG2opdfyyQ==, + } + engines: { node: ">=18" } + cpu: [riscv64] + os: [linux] + + "@esbuild/linux-s390x@0.28.1": + resolution: + { + integrity: sha512-/lAIjX8aYFRByhh6L5rYtPEDRqa9de/4V/juOXcta5frjvzXO4/sqEtyytse0g3zZFuWu5cDN0MkLz2qRDD2Ag==, + } + engines: { node: ">=18" } + cpu: [s390x] + os: [linux] + + "@esbuild/linux-x64@0.28.1": + resolution: + { + integrity: sha512-u/anNYF2mmVOEDwLtnQ1wOr3EZ9sTNGLWrsYGYwHWzGA3Si84IOkHXlbWTD1NB+9/1lcnweYKO54uhxZydNzfA==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [linux] + + "@esbuild/netbsd-arm64@0.28.1": + resolution: + { + integrity: sha512-oks0DYbLwWMmaakTsCb+zL4E+aHRVLom9IJZOAthMQEPiQmydXHkziYEsGYRx0uNV/IjEKGAV941JzH02pflqw==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [netbsd] + + "@esbuild/netbsd-x64@0.28.1": + resolution: + { + integrity: sha512-aeL6lAnN89Hz43Mlh1G8ARasbuoYvSITDEx0tHh5b7jJnHcssqgjy9Yx430GDpmCa6OyrKoS0aNRjKundRizGg==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [netbsd] + + "@esbuild/openbsd-arm64@0.28.1": + resolution: + { + integrity: sha512-MEFJe5C3R8pwXdZ5Y21oo6m7ePiS0d9pWucn99O/wvyJZChoIQKrQDxKrGeW8F5+T0okTHesAmDeiHDTIq0V/Q==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openbsd] + + "@esbuild/openbsd-x64@0.28.1": + resolution: + { + integrity: sha512-i/ZLIOafE0Z8cI/XANJAixoJL/uRAoS2xOA3rb0xN+KK0K177cMAsQYkzHtBrtMXAKuAc7HGgcWiZ/sRC1Nxgw==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [openbsd] + + "@esbuild/openharmony-arm64@0.28.1": + resolution: + { + integrity: sha512-ge+Z7EXFNt2BO1oAMsVpiQ8EwndV9i1xXerAeTIK7AtPs3bKFXQM7nlRxDSIUIMeueR1CNXxqztLzdNeReKBJg==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [openharmony] + + "@esbuild/sunos-x64@0.28.1": + resolution: + { + integrity: sha512-BEjgtECkL3vY+SaSQ6nzVfiALUeFxpawyp8Jmf5PtYhf1Ug40N1h/hxlhts+f1FvSvarEigdxS3BlSMI2PJLcQ==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [sunos] + + "@esbuild/win32-arm64@0.28.1": + resolution: + { + integrity: sha512-lCv9eK/H6ZJWbE7bh2nw54CZ9M2nupBxJcTsdk/QQnWkdSjKGuxmmH8/GWrlT1eMmZfn4dGcCjRte397WqfQXA==, + } + engines: { node: ">=18" } + cpu: [arm64] + os: [win32] + + "@esbuild/win32-ia32@0.28.1": + resolution: + { + integrity: sha512-zvb/mB2bSCoJOpoCBgYKKpX6YM6mJBlBUVUtVj41DlZJVEB6/0CKlRYxP5wWl1C1ILiCoAU5wZZ4q1P3qeS6Eg==, + } + engines: { node: ">=18" } + cpu: [ia32] + os: [win32] + + "@esbuild/win32-x64@0.28.1": + resolution: + { + integrity: sha512-bm4Mowrv+GXMlpWX++EcXw/iLyd1o3+bJkC2DkWXYVvgZCqD/bSj9ctZeAMC3cIxgjRVR2Dufaiu4YPxr5gW1A==, + } + engines: { node: ">=18" } + cpu: [x64] + os: [win32] + + "@eslint-community/eslint-utils@4.10.1": + resolution: + { + integrity: sha512-cuadcxVFE8sDK6iWJbs8Sn0av2Nrh2QSGQhVlBW9AaAHqHwjWsZHT8LJ4hFGPh7ASBV2deFdM7H/DPjulmh8rg==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + + "@eslint-community/regexpp@4.12.2": + resolution: + { + integrity: sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==, + } + engines: { node: ^12.0.0 || ^14.0.0 || >=16.0.0 } + + "@eslint/config-array@0.21.2": + resolution: + { + integrity: sha512-nJl2KGTlrf9GjLimgIru+V/mzgSK0ABCDQRvxw5BjURL7WfH5uoWmizbH7QB6MmnMBd8cIC9uceWnezL1VZWWw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/config-helpers@0.4.2": + resolution: + { + integrity: sha512-gBrxN88gOIf3R7ja5K9slwNayVcZgK6SOUORm2uBzTeIEfeVaIhOpCtTox3P6R7o2jLFwLFTLnC7kU/RGcYEgw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/core@0.17.0": + resolution: + { + integrity: sha512-yL/sLrpmtDaFEiUj1osRP4TI2MDz1AddJL+jZ7KSqvBuliN4xqYY54IfdN8qD8Toa6g1iloph1fxQNkjOxrrpQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/eslintrc@3.3.6": + resolution: + { + integrity: sha512-l2Ul9PrHsPCKcEY/ac7VgFj9D80C7S68sOKc618SyHDPK36s1XcFebXY0iTzUVn4Yq+YbwvSnDmCz9yxjX+QrA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/js@9.39.5": + resolution: + { + integrity: sha512-QywQuszQh77pIXCsq998c8hbhSTI/azTty1Z6N53dmAudKHhy573j3yvRLsX2BSp8YpLtoCEG8E9DJe+8zUh4A==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/object-schema@2.1.7": + resolution: + { + integrity: sha512-VtAOaymWVfZcmZbp6E2mympDIHvyjXs/12LqWYjVw6qjrfF+VK+fyG33kChz3nnK+SU5/NeHOqrTEHS8sXO3OA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@eslint/plugin-kit@0.4.1": + resolution: + { + integrity: sha512-43/qtrDUokr7LJqoF2c3+RInu/t4zfrpYdoSDfYyhg52rwLV6TnOvdG4fXm7IkSB3wErkcmJS9iEhjVtOSEjjA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@humanfs/core@0.19.2": + resolution: + { + integrity: sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==, + } + engines: { node: ">=18.18.0" } + + "@humanfs/node@0.16.8": + resolution: + { + integrity: sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==, + } + engines: { node: ">=18.18.0" } + + "@humanfs/types@0.15.0": + resolution: + { + integrity: sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==, + } + engines: { node: ">=18.18.0" } + + "@humanwhocodes/module-importer@1.0.1": + resolution: + { + integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==, + } + engines: { node: ">=12.22" } + + "@humanwhocodes/retry@0.4.3": + resolution: + { + integrity: sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==, + } + engines: { node: ">=18.18" } + + "@img/colour@1.1.0": + resolution: + { + integrity: sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==, + } + engines: { node: ">=18" } + + "@img/sharp-darwin-arm64@0.35.3": + resolution: + { + integrity: sha512-RMnFX7YQsMoh7lWfcM4NEHHymBX/rLuKNPVM84XE9ONPcaSCDgE7CHIHpSgPcO2xcRthgBy1HfNO319mwhIAkg==, + } + engines: { node: ">=20.9.0" } + cpu: [arm64] + os: [darwin] + + "@img/sharp-darwin-x64@0.35.3": + resolution: + { + integrity: sha512-Xo+5uFBtLN0BKqieTxiFzFPQAUlBbbH5iBKyRX/z1JrbnYsHTfKJnUfL8+p2TPXr1pXqao4eeL4Rl144uDpK9w==, + } + engines: { node: ">=20.9.0" } + cpu: [x64] + os: [darwin] + + "@img/sharp-freebsd-wasm32@0.35.3": + resolution: + { + integrity: sha512-lUxcqWIj2wMQ9BrwNjngcr1gWUr5xgaGThBRqPPalIC2n67Cqj1uPh8NnA/ZhAg8hUbKl+kVHKwgUIwe6ZYPrg==, + } + engines: { node: ">=20.9.0" } + os: [freebsd] + + "@img/sharp-libvips-darwin-arm64@1.3.2": + resolution: + { + integrity: sha512-9J6ypZFpQBj4YnePGoq/S38w6nz+vqg5WZLrLGY4YuSemdMq47GMLBPO42MzwdGwpg/agZ7xzZcFHa48xlywfg==, + } + cpu: [arm64] + os: [darwin] + + "@img/sharp-libvips-darwin-x64@1.3.2": + resolution: + { + integrity: sha512-m2pW1n6cns9VaubNwsZ+c3CRYjxNQWgJ5gPlnL1nbBcpkBvFm6SCFN5o0psFHI8w9n11NKhFkeEDns98tiqbEw==, + } + cpu: [x64] + os: [darwin] + + "@img/sharp-libvips-linux-arm64@1.3.2": + resolution: + { + integrity: sha512-dqVSFynCox4C/J8kT16V7SIFAns0IjgLwkvYT7p8LQVmJ5OS5b6tI9IGflxTeuBS//zXeFIUbwt5dwxyZ17cnA==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linux-arm@1.3.2": + resolution: + { + integrity: sha512-1eMLzy92I4J6rmi4mAT8yC3HxOtniyGELlzGbNMLLeqe052ahFQ0h6LFq+lh5DsDIdYViIDst08abvSbcEdLXQ==, + } + cpu: [arm] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linux-ppc64@1.3.2": + resolution: + { + integrity: sha512-3z0NHDxD6n5I9gc05U1eW1AyRm+Gznzq3naMrthPNqE6oYykcogW0l/jfpJdjYnuNl8R7yI9pNbE1XiUeyq0Aw==, + } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linux-riscv64@1.3.2": + resolution: + { + integrity: sha512-bsb4rI+NldGOsXuej2r8OdSS8+zXDVaCWxyWrcv6kneTOlgAHtZABRzBBCwdsPiD90J4myNJuHpg6kA20ImW/w==, + } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linux-s390x@1.3.2": + resolution: + { + integrity: sha512-/ABshyj8gCpyIrNXnHn4LorDJ0HHm1VhXPBlxZ8zAtfVPAaSafXPGn+sUSIRiwaSBy0mmFjSjiXI5mkcwdChKQ==, + } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linux-x64@1.3.2": + resolution: + { + integrity: sha512-ITPEtgffGJ0S6G9dRyw/366tJQqFRcHWPHhC+Stpg3Z8AEMrDrTr2lhdz4f/Y/HMbRh//7Z5mBzEpVdi62Oc3w==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@img/sharp-libvips-linuxmusl-arm64@1.3.2": + resolution: + { + integrity: sha512-zE9EdiUzUmg5mDT5a1rk5fYJ6GWPloTwWBYDS14naqHsL+EaMpDj1AWnpLgh3u0YCORv2Tt50wrcrpYqkP97Kw==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@img/sharp-libvips-linuxmusl-x64@1.3.2": + resolution: + { + integrity: sha512-m0lrLiUt+lBYnCFr8qV/65yMR4E/c7/wf78I5eKTdkEakFAlZ9QlzEM3QIhhAwVeUhLAHLcCq7a7Vszq/oFNZQ==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + "@img/sharp-linux-arm64@0.35.3": + resolution: + { + integrity: sha512-QgKDspHPnrU+GQ55XPhGwyhC8acLVOOSyAvo1oVfFmrIXLkDNmGWzAfDZ4xK8oSA1qBQrALcHX0G5UZni/SuFQ==, + } + engines: { node: ">=20.9.0" } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@img/sharp-linux-arm@0.35.3": + resolution: + { + integrity: sha512-affVWCTLooy8TSxbDx2qkzuDeaWLNVBA+P//FNBirHsXpP2fuBhk5AuboYUnrDnzoXes8GFjpTx0SBFOCRg+FA==, + } + engines: { node: ">=20.9.0" } + cpu: [arm] + os: [linux] + libc: [glibc] + + "@img/sharp-linux-ppc64@0.35.3": + resolution: + { + integrity: sha512-sMd8rDxmpLOwv/7N44klFjOD5DUO7FLdjiXDI0hoxYaf7Ar262dQIEkosE98bps+5HPLtp/EvNqeqQtOycP/IA==, + } + engines: { node: ">=20.9.0" } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@img/sharp-linux-riscv64@0.35.3": + resolution: + { + integrity: sha512-0Eob78yjlYPfL5vMNWAW55l3R9Y6BQS/gOfe0ZcP9mEz9ohhKSt4im1hayiknXgf8AWrFqMvJcKIdmLmEe7yeQ==, + } + engines: { node: ">=20.9.0" } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + "@img/sharp-linux-s390x@0.35.3": + resolution: + { + integrity: sha512-KgAxQ0DxpNOq1rG2t5cgTgShJFGSuU7XO45cqC+1NVOuZnP6tlgZRuSYOfNupGkHID0o3cJOsw4DVeJpMovcGw==, + } + engines: { node: ">=20.9.0" } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@img/sharp-linux-x64@0.35.3": + resolution: + { + integrity: sha512-8pqvxubL2PGdhlPy6GLqzDYMUjyRmKAwKHYKixpdJYBUK7PJ0C029XdsnpFIdgRZG68fZiGdHVWcKPvtiPB4cA==, + } + engines: { node: ">=20.9.0" } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@img/sharp-linuxmusl-arm64@0.35.3": + resolution: + { + integrity: sha512-Vz0iQjzzcSX3HCbfwFfCSG/9SCIqyO0mH2sXyiHaAYfBk0cRsCWXRyQYX0ovCK/PAQBbTzQ0dsPQHh5MAFL59w==, + } + engines: { node: ">=20.9.0" } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@img/sharp-linuxmusl-x64@0.35.3": + resolution: + { + integrity: sha512-6O1NPKcDVj9QEdg7Hx549EX8U0rp6yXQERqru6yRN7fGBn32UvIRJUlWnk+8xDCiG76hXVBbX82NZ/ZKr0euIg==, + } + engines: { node: ">=20.9.0" } + cpu: [x64] + os: [linux] + libc: [musl] + + "@img/sharp-wasm32@0.35.3": + resolution: + { + integrity: sha512-cZ0XkcYGpHZkqW6iCkqTcmUC0CD9DhD5d/qeZlZkfRBn6GnHniZXLUo5+9xw8Iv76YE6LQFN9YNBlKREcCG76w==, + } + engines: { node: ">=20.9.0" } + + "@img/sharp-webcontainers-wasm32@0.35.3": + resolution: + { + integrity: sha512-2rnq7bX3NzeR2T4YWgz8qiG4h3TSdMe+vN1iQXpJleSJ3SM5zQ8Fy2SyyXAWlbxpEZ2Y+Z4u1BePgJEYbSy80Q==, + } + engines: { node: ">=20.9.0" } + cpu: [wasm32] + + "@img/sharp-win32-arm64@0.35.3": + resolution: + { + integrity: sha512-4bPwFdMbeC4JQ8L8LOyWp6nsHcboP5fxkp6iPOXz2Vg49R42TuMs2whkJ5OAP4/Ul035qOzy0AecOF9VOscn4w==, + } + engines: { node: ">=20.9.0" } + cpu: [arm64] + os: [win32] + + "@img/sharp-win32-ia32@0.35.3": + resolution: + { + integrity: sha512-r53mXsBN6lFUDiST764SvgwUdHAqM4rPAiDzAmf4fLoB6X/rkfyTrLCg6+g17wJJiCmB3JYgHuUldCWUIRFSXw==, + } + engines: { node: ^20.9.0 } + cpu: [ia32] + os: [win32] + + "@img/sharp-win32-x64@0.35.3": + resolution: + { + integrity: sha512-D4y1vNeZrIIJCN+uHaWVtH86B+aCrdMYYjicy9pXHvbGZeGYLLSd3wdVuC37FxVXlU1ARsk84eKWfWMXGYEqvA==, + } + engines: { node: ">=20.9.0" } + cpu: [x64] + os: [win32] + + "@jridgewell/sourcemap-codec@1.5.5": + resolution: + { + integrity: sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==, + } + + "@rollup/rollup-android-arm-eabi@4.62.3": + resolution: + { + integrity: sha512-c0wdcekXtQvvn5Tsrk/+op/gUArrbWaFduBnTLP2l1cKLSQs4diMWjJw3m6A0DdzT8dAAX95KpkJ3qynCePbmw==, + } + cpu: [arm] + os: [android] + + "@rollup/rollup-android-arm64@4.62.3": + resolution: + { + integrity: sha512-3YjElDdWN+qXAFbJ/CzPV+0wspLqh54k/I6GfdYtEJRqg7buSgc1yPM3B+93j1M4neobtkATHZTmxK2AMVGfnA==, + } + cpu: [arm64] + os: [android] + + "@rollup/rollup-darwin-arm64@4.62.3": + resolution: + { + integrity: sha512-Pch2pFNOxxz1hTjypIdPyRTR6riiwRl84+VcN9djS680fw+Co1nAJINrdpqp7KV0NvyuU8ilZXZCjd7ykJl1GQ==, + } + cpu: [arm64] + os: [darwin] + + "@rollup/rollup-darwin-x64@4.62.3": + resolution: + { + integrity: sha512-LEuncFUHFiF8t4yZVZvvZA1wk0pjAscRnsrn1EfTEmN4HXotBi2YtcnLRyaK6UbuczW7xZS5ES+81Rdz8Z0T6g==, + } + cpu: [x64] + os: [darwin] + + "@rollup/rollup-freebsd-arm64@4.62.3": + resolution: + { + integrity: sha512-zvBUvsQUpOWALdDsk6qbS8bXf2VxmPisuudNDrY7x0p0jBdsoZl8HsHczIOgkQiZldmcacMKtBzpoGVNeIe2bQ==, + } + cpu: [arm64] + os: [freebsd] + + "@rollup/rollup-freebsd-x64@4.62.3": + resolution: + { + integrity: sha512-C2KmNrcSem/AMg984H/dev+si0lieQGdXdR/lYGJnuumXnFb9Y7QdiI62obFdLlxRYLBv4P0eUVIDbD4c1vVvw==, + } + cpu: [x64] + os: [freebsd] + + "@rollup/rollup-linux-arm-gnueabihf@4.62.3": + resolution: + { + integrity: sha512-ggXnsTAEzNQx74XpunRsiZ9aBZDsI7XIa0hm2nzR9f4WzH5/f/d73ZSDaC5ejJ8YLY4NW+V3wr0tjOaeCq8hqA==, + } + cpu: [arm] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-arm-musleabihf@4.62.3": + resolution: + { + integrity: sha512-2vng+FlzNUhKZxtej3IUqJgbZoQk2M/dwQM20+ULV0R/E/8tr9/P6uEf2iiGIk4HL0zMKh5Jry7mUHdUOvyGgA==, + } + cpu: [arm] + os: [linux] + libc: [musl] + + "@rollup/rollup-linux-arm64-gnu@4.62.3": + resolution: + { + integrity: sha512-LLLFZKt4/Nraf9rxDkhiU8QVgLF4WmCkfr0L4fj0fPfIZFBib0DeiFk1hhaYKd03LFAFJcxHslhDFlNJLylf5Q==, + } + cpu: [arm64] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-arm64-musl@4.62.3": + resolution: + { + integrity: sha512-WJkdQCvS9sWNOUBJZfQRKpZGFBztRzcowI+nndmflKgU4XY+3a420FgTOSKTsVqJbnzSxeT4vaJalpOaPo2YCQ==, + } + cpu: [arm64] + os: [linux] + libc: [musl] + + "@rollup/rollup-linux-loong64-gnu@4.62.3": + resolution: + { + integrity: sha512-PwHXCCS2n64/1Ot6rP1YEYA02MGYBcQlr8CSZZyrUG2O7NH6NklYmvr9v3Jy+5e/eDeNchc/ukmKJi9LuflMIQ==, + } + cpu: [loong64] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-loong64-musl@4.62.3": + resolution: + { + integrity: sha512-vUjxINQu3RC8NZS3ykk1gN65gIz8pAopOq2HXuZhiIxHdx7TFvDG+jgrdSgInu1Eza4/Rfi2VzZgyIgEH4WOaw==, + } + cpu: [loong64] + os: [linux] + libc: [musl] + + "@rollup/rollup-linux-ppc64-gnu@4.62.3": + resolution: + { + integrity: sha512-wzko4aJ13+0G3kGnviCg5gnXFKd40izKsrf2uOw12US4XqprkDrmwOpeW14aSNa37V8bfPcz5Fkob6LZ3BAPmA==, + } + cpu: [ppc64] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-ppc64-musl@4.62.3": + resolution: + { + integrity: sha512-8120ue0JUMSwy11stlwnfdX3pPd+WZYGCDBwEHWtIHi6pOpZmsEF5QKB7a/UN+XFdqvobxz98kv8RTqikyCEBw==, + } + cpu: [ppc64] + os: [linux] + libc: [musl] + + "@rollup/rollup-linux-riscv64-gnu@4.62.3": + resolution: + { + integrity: sha512-XLFHnR3tXMjbOCh2vtVJHmxt+995uJsTERQyseFDRA0xxMxyTZPLa3OIUlyFaO4mF/Lu0FjmWHCuPXJT1n/IOg==, + } + cpu: [riscv64] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-riscv64-musl@4.62.3": + resolution: + { + integrity: sha512-se6yXvNGMIl0f+RQzyh7XAmia8/9kplQx424wnG2w0C1oi6XgO6Y8otKhdXFHbHs88Ihavzmvh1NWjuovE76BQ==, + } + cpu: [riscv64] + os: [linux] + libc: [musl] + + "@rollup/rollup-linux-s390x-gnu@4.62.3": + resolution: + { + integrity: sha512-gNoxRefktVIiGflpONuxWWXZAzIQG++z9qHO3xKwk4WdDMuQja3JHGfE1u0i3PfPDyvhypdk+WrgIJqLhGG7sg==, + } + cpu: [s390x] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-x64-gnu@4.62.3": + resolution: + { + integrity: sha512-V4KtWtQfAFMU7+9/A/VDps/VI8CHd3cYz0L8sgJzz8qK7eY7wI4ruFD82UYIYvW9Z4DtlTfhQcsl4XyPHW5uSg==, + } + cpu: [x64] + os: [linux] + libc: [glibc] + + "@rollup/rollup-linux-x64-musl@4.62.3": + resolution: + { + integrity: sha512-LBx9LYXvj2CBkMkjLdNAWLwH0MLMin7do2VcVo9kVPibGLkY0BQQut2fv7NVqkXqZ/CrAu9LqDHVV1xHCMpCPw==, + } + cpu: [x64] + os: [linux] + libc: [musl] + + "@rollup/rollup-openbsd-x64@4.62.3": + resolution: + { + integrity: sha512-ABVf3Q0RCu7NcyCCOZQI0pJ3GuSdfSl8EXcy88QtdceIMIoCUdfhsJChZ64L9zVM2aJHjde1Bhn5uqSRcX9ySA==, + } + cpu: [x64] + os: [openbsd] + + "@rollup/rollup-openharmony-arm64@4.62.3": + resolution: + { + integrity: sha512-+2Cy/ldweGBLlPIKsQLF8U5N44a0KDdbrk1rAjHOM9M2K+kGdIVjHLmmrZIcx+9Ny3ke/1JomCsDI1ocb11+sg==, + } + cpu: [arm64] + os: [openharmony] + + "@rollup/rollup-win32-arm64-msvc@4.62.3": + resolution: + { + integrity: sha512-dtZvzc8BedpSaFNy75x6uiWwAGTH+aZHDtdrqP6qk+WcLJrfti6sGje1ZJ9UxyzDLF23d/mV+PaMwuC0hL7UVA==, + } + cpu: [arm64] + os: [win32] + + "@rollup/rollup-win32-ia32-msvc@4.62.3": + resolution: + { + integrity: sha512-Rj8Ra4noo+aYy7sKBggCx0407mws34kAb1ySyWuq5DAtFBQdkSwnsjCgPrhPe9cvgBKZIukpE+CVHvORCS93kQ==, + } + cpu: [ia32] + os: [win32] + + "@rollup/rollup-win32-x64-gnu@4.62.3": + resolution: + { + integrity: sha512-vp7N084ew/odXn2gi/mzm9mUkQu9l6AiN6dt4IeUM2Uvm9o+cVmP+YkqbMOteLbiGgqBBlJZjIMYVCfOOIVbVQ==, + } + cpu: [x64] + os: [win32] + + "@rollup/rollup-win32-x64-msvc@4.62.3": + resolution: + { + integrity: sha512-MOG/3gTOn4Fwf574RVOaY61I5o6P90legkFADiTyn1hyjNydT+cerU2rLUwPdZkKKyJ+iT+K9p7WXK4LM1Ka6g==, + } + cpu: [x64] + os: [win32] + + "@types/chai@5.2.3": + resolution: + { + integrity: sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==, + } + + "@types/deep-eql@4.0.2": + resolution: + { + integrity: sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==, + } + + "@types/estree@1.0.9": + resolution: + { + integrity: sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==, + } + + "@types/json-schema@7.0.15": + resolution: + { + integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==, + } + + "@types/node@22.20.1": + resolution: + { + integrity: sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==, + } + + "@typescript-eslint/eslint-plugin@8.65.0": + resolution: + { + integrity: sha512-IEgob78X12rHpUmtcwFsXhZdVGJtwTVP8FiCLZkR6GlYVrl2PcuB+KhCE5BlVC/eQpQnu8WXRtkHZuPar+gCRA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + "@typescript-eslint/parser": ^8.65.0 + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/parser@8.65.0": + resolution: + { + integrity: sha512-CZ4nMxWwgu1HEEFNkeaCptra9QCtkmKdgf3sWh1rl1trIhmxLilgTV4cwcbQ4wemnT4sWQN8CaKOmdYx+g2gMA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/project-service@8.65.0": + resolution: + { + integrity: sha512-SxnPhbTsGahizDgbu7oqFH/xVtzIqMd/s+WtnSxNxJZJpLbdT5IPdzg8EZxO3+PoKahXmwJLeNQOpKJb3/bi7Q==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/scope-manager@8.65.0": + resolution: + { + integrity: sha512-Esbl8OSYiVxBokYgWPf7VVWg/BE798wXhimnn9ML9Pt5qoDf8bfQlgjlKXR/k98+AcNzlLKYrpCcrcuZ9DZLgg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/tsconfig-utils@8.65.0": + resolution: + { + integrity: sha512-j6GzGqCiRdA7Qhur2VVmKZAkBLfnHFQfx4TaJGL9RMveZqCo48jSHHO0DTgizEnGhtWnqmbtCUSrqSkdiY/0Hg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/type-utils@8.65.0": + resolution: + { + integrity: sha512-YjaZ7PRI5qY7ax2L3PbvX0rRyGtipAReCWs0mhhDBHjH/vl0g0BonaGXrKdKpMbIIsMIwDgbk/xzkBTyAltS5g==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/types@8.65.0": + resolution: + { + integrity: sha512-JSSwWNy+H0E/01jJEM+hrX6N0OFDzFzeIhHFSAS01tlVaevpG8cFyYRPhS5yjGOvBUx3sqQHVMjCL1CAZZMxBg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@typescript-eslint/typescript-estree@8.65.0": + resolution: + { + integrity: sha512-JboAE2swaYt4tb1fHhHTABE2K+OLy09XfcTbhnk4Pw96f9dd2e9iYsJ28gBggHlo5z5x1rkyWvcPoTuNTd4oGg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/utils@8.65.0": + resolution: + { + integrity: sha512-gXiwIHsYreboxeJucHKPvgwl7dXt50mF8s1/c00cP/WoVTyWKFdtfhRWwZiXYFU5H2O8vVoSLNrexFZjYS/SGA==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + peerDependencies: + eslint: ^8.57.0 || ^9.0.0 || ^10.0.0 + typescript: ">=4.8.4 <6.1.0" + + "@typescript-eslint/visitor-keys@8.65.0": + resolution: + { + integrity: sha512-8C71BQkGjiMmXtop7pHVJu1l2NNShFdkCyD6a2ezzs5vU/L3LRtb69EtcteFwz0mYMPzIgOw0n6OV4VBUWZd7A==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + "@vitest/expect@3.2.7": + resolution: + { + integrity: sha512-E8eBXaKibuvH2pSZErOjdVb5vF4PbKYcrnluBTYxEk1l/VhhwZg1kZQsdtjq+CsF5CFydf2Rdkz7jDHKSisi3w==, + } + + "@vitest/mocker@3.2.7": + resolution: + { + integrity: sha512-Trr0hYO9CM3Wj6ksWHRhK9IZpIY6wTMO5u/MqXurMxT57sWBaOPEtP3Oq60ihZuh5JsiagKfz95OcxdEP6dBrA==, + } + peerDependencies: + msw: ^2.4.9 + vite: ^5.0.0 || ^6.0.0 || ^7.0.0-0 + peerDependenciesMeta: + msw: + optional: true + vite: + optional: true + + "@vitest/pretty-format@3.2.7": + resolution: + { + integrity: sha512-KUHlwqVu0sRlhCdyPdQ/wBoTfRahjUky1MubOmYw9fWfIZy1gNoHpuaaQBPAaMaVYdQYHJLurzj8ECCj5OwTqA==, + } + + "@vitest/runner@3.2.7": + resolution: + { + integrity: sha512-sB9y4ovltoQP+WaUPwmSxO9WIg9Ig694Di5PalVPsYHklAdE027mehpWF2SQSVq+k6sFgaivbTjTJwZLSHbedA==, + } + + "@vitest/snapshot@3.2.7": + resolution: + { + integrity: sha512-7C+MwShwtBSI5Buwoyg3s/iY1eHL9PKAf+O1wVh/TdnjXUtkoL/9YQtre90i4MtNXM6edP1wJ2zOBpfCyhIS7g==, + } + + "@vitest/spy@3.2.7": + resolution: + { + integrity: sha512-Q2eQGI6d2L/hBtZ0qNuKcAGid68XK6cv1xsoaIma6PaJhHPoqcEJhYpXZ/5myCMqkNgtP6UKuBhbc0nHKnrkuQ==, + } + + "@vitest/utils@3.2.7": + resolution: + { + integrity: sha512-x6BDOd7dyo3PFLY3I9/HJ25X/6OurhGXk2/B9gOZNPF7XDVjeBK4k01lQE5uvDpbuheErh91qYuE1E2OEjK3Rw==, + } + + acorn-jsx@5.3.2: + resolution: + { + integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==, + } + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + + acorn@8.18.0: + resolution: + { + integrity: sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ==, + } + engines: { node: ">=0.4.0" } + hasBin: true + + ajv@6.15.0: + resolution: + { + integrity: sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==, + } + + ansi-styles@4.3.0: + resolution: + { + integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==, + } + engines: { node: ">=8" } + + argparse@2.0.1: + resolution: + { + integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==, + } + + assertion-error@2.0.1: + resolution: + { + integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==, + } + engines: { node: ">=12" } + + balanced-match@1.0.2: + resolution: + { + integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==, + } + + balanced-match@4.0.4: + resolution: + { + integrity: sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==, + } + engines: { node: 18 || 20 || >=22 } + + brace-expansion@1.1.18: + resolution: + { + integrity: sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==, + } + + brace-expansion@5.0.9: + resolution: + { + integrity: sha512-ScQ4IuvIEF1TMlP7Zt+vjJ//9zlPb2SDcxWxM3bk8s6t6GGdJ7KO1dCcTidOPJKePW30LE/2cT7wCyPho9/Wxg==, + } + engines: { node: 20 || >=22 } + + cac@6.7.14: + resolution: + { + integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==, + } + engines: { node: ">=8" } + + callsites@3.1.0: + resolution: + { + integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==, + } + engines: { node: ">=6" } + + chai@5.3.3: + resolution: + { + integrity: sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw==, + } + engines: { node: ">=18" } + + chalk@4.1.2: + resolution: + { + integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==, + } + engines: { node: ">=10" } + + check-error@2.1.3: + resolution: + { + integrity: sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA==, + } + engines: { node: ">= 16" } + + color-convert@2.0.1: + resolution: + { + integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==, + } + engines: { node: ">=7.0.0" } + + color-name@1.1.4: + resolution: + { + integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==, + } + + concat-map@0.0.1: + resolution: + { + integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==, + } + + cross-spawn@7.0.6: + resolution: + { + integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==, + } + engines: { node: ">= 8" } + + debug@4.4.3: + resolution: + { + integrity: sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==, + } + engines: { node: ">=6.0" } + peerDependencies: + supports-color: "*" + peerDependenciesMeta: + supports-color: + optional: true + + deep-eql@5.0.2: + resolution: + { + integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==, + } + engines: { node: ">=6" } + + deep-is@0.1.4: + resolution: + { + integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==, + } + + detect-libc@2.1.2: + resolution: + { + integrity: sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==, + } + engines: { node: ">=8" } + + es-module-lexer@1.7.0: + resolution: + { + integrity: sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==, + } + + esbuild@0.28.1: + resolution: + { + integrity: sha512-HrJrvZv5ayxBzPfwphOoNzkzOIIlifzk0KJrGK2c8R4+LKpMtpYLQeUdjnwjWv/LZlkH2laZk+4w78pi99D4Vw==, + } + engines: { node: ">=18" } + hasBin: true + + escape-string-regexp@4.0.0: + resolution: + { + integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==, + } + engines: { node: ">=10" } + + eslint-scope@8.4.0: + resolution: + { + integrity: sha512-sNXOfKCn74rt8RICKMvJS7XKV/Xk9kA7DyJr8mJik3S7Cwgy3qlkkmyS2uQB3jiJg6VNdZd/pDBJu0nvG2NlTg==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@3.4.3: + resolution: + { + integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==, + } + engines: { node: ^12.22.0 || ^14.17.0 || >=16.0.0 } + + eslint-visitor-keys@4.2.1: + resolution: + { + integrity: sha512-Uhdk5sfqcee/9H/rCOJikYz67o0a2Tw2hGRPOG2Y1R2dg7brRe1uG0yaNQDHu+TO/uQPF/5eCapvYSmHUjt7JQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + eslint-visitor-keys@5.0.1: + resolution: + { + integrity: sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==, + } + engines: { node: ^20.19.0 || ^22.13.0 || >=24 } + + eslint@9.39.5: + resolution: + { + integrity: sha512-DgZS62aPLXKlnxILS/AYCoRvHaZeXceIzlXPkkGGzJWSow1aEk0lbTlxUSlyjC8jcaKxAdOnTDz+o1JFSBsyjw==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + hasBin: true + peerDependencies: + jiti: "*" + peerDependenciesMeta: + jiti: + optional: true + + espree@10.4.0: + resolution: + { + integrity: sha512-j6PAQ2uUr79PZhBjP5C5fhl8e39FmRnOjsD5lGnWrFU8i2G776tBK7+nP8KuQUTTyAZUwfQqXAgrVH5MbH9CYQ==, + } + engines: { node: ^18.18.0 || ^20.9.0 || >=21.1.0 } + + esquery@1.7.0: + resolution: + { + integrity: sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==, + } + engines: { node: ">=0.10" } + + esrecurse@4.3.0: + resolution: + { + integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==, + } + engines: { node: ">=4.0" } + + estraverse@5.3.0: + resolution: + { + integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==, + } + engines: { node: ">=4.0" } + + estree-walker@3.0.3: + resolution: + { + integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==, + } + + esutils@2.0.3: + resolution: + { + integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==, + } + engines: { node: ">=0.10.0" } + + expect-type@1.4.0: + resolution: + { + integrity: sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==, + } + engines: { node: ">=12.0.0" } + + fast-check@4.9.0: + resolution: + { + integrity: sha512-7ms6T7SybUev/PQITciI0yLM2pOSFy5zpG8Ty7tQofcVaQUvrMXp6CBwqF6fThLCLOrfBtuHAtwq6Yu4XPCllg==, + } + engines: { node: ">=12.17.0" } + + fast-deep-equal@3.1.3: + resolution: + { + integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==, + } + + fast-json-stable-stringify@2.1.0: + resolution: + { + integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==, + } + + fast-levenshtein@2.0.6: + resolution: + { + integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==, + } + + fdir@6.5.0: + resolution: + { + integrity: sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==, + } + engines: { node: ">=12.0.0" } + peerDependencies: + picomatch: ^3 || ^4 + peerDependenciesMeta: + picomatch: + optional: true + + file-entry-cache@8.0.0: + resolution: + { + integrity: sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==, + } + engines: { node: ">=16.0.0" } + + find-up@5.0.0: + resolution: + { + integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==, + } + engines: { node: ">=10" } + + flat-cache@4.0.1: + resolution: + { + integrity: sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==, + } + engines: { node: ">=16" } + + flatted@3.4.4: + resolution: + { + integrity: sha512-5+ybhBZANEJxaH3X5evAFatUxLfEHSr7n6kYJ+1Qd0mUqr4eu9gIf6GDbWHf8RJijHrjjO8G+la14SlL2SeS1Q==, + } + + fsevents@2.3.3: + resolution: + { + integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==, + } + engines: { node: ^8.16.0 || ^10.6.0 || >=11.0.0 } + os: [darwin] + + glob-parent@6.0.2: + resolution: + { + integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==, + } + engines: { node: ">=10.13.0" } + + globals@14.0.0: + resolution: + { + integrity: sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==, + } + engines: { node: ">=18" } + + globals@16.5.0: + resolution: + { + integrity: sha512-c/c15i26VrJ4IRt5Z89DnIzCGDn9EcebibhAOjw5ibqEHsE1wLUgkPn9RDmNcUKyU87GeaL633nyJ+pplFR2ZQ==, + } + engines: { node: ">=18" } + + has-flag@4.0.0: + resolution: + { + integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==, + } + engines: { node: ">=8" } + + ignore@5.3.2: + resolution: + { + integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==, + } + engines: { node: ">= 4" } + + ignore@7.0.6: + resolution: + { + integrity: sha512-BAg6QkE8W+TuQLrrw0Ugr7HegXduRuuj8/ti2kSOc+jz1dmx8/WNcjr6XGnq5YpDWxFwwaavqD0+jIUOKelTsw==, + } + engines: { node: ">= 4" } + + import-fresh@3.3.1: + resolution: + { + integrity: sha512-TR3KfrTZTYLPB6jUjfx6MF9WcWrHL9su5TObK4ZkYgBdWKPOFoSoQIdEuTuR82pmtxH2spWG9h6etwfr1pLBqQ==, + } + engines: { node: ">=6" } + + imurmurhash@0.1.4: + resolution: + { + integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==, + } + engines: { node: ">=0.8.19" } + + is-extglob@2.1.1: + resolution: + { + integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==, + } + engines: { node: ">=0.10.0" } + + is-glob@4.0.3: + resolution: + { + integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==, + } + engines: { node: ">=0.10.0" } + + isexe@2.0.0: + resolution: + { + integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==, + } + + js-tokens@9.0.1: + resolution: + { + integrity: sha512-mxa9E9ITFOt0ban3j6L5MpjwegGz6lBQmM1IJkWeBZGcMxto50+eWdjC/52xDbS2vy0k7vIMK0Fe2wfL9OQSpQ==, + } + + js-yaml@4.3.0: + resolution: + { + integrity: sha512-1td788aAnnZ5qs7V2QIRl1owjtYpbKt749Y3xauqQgwIIGF/xXWz1wMTEBx5O3LK3lXLVuqXPdPxj2BoFHaW9Q==, + } + hasBin: true + + json-buffer@3.0.1: + resolution: + { + integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==, + } + + json-schema-traverse@0.4.1: + resolution: + { + integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==, + } + + json-stable-stringify-without-jsonify@1.0.1: + resolution: + { + integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==, + } + + keyv@4.5.4: + resolution: + { + integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==, + } + + levn@0.4.1: + resolution: + { + integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==, + } + engines: { node: ">= 0.8.0" } + + locate-path@6.0.0: + resolution: + { + integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==, + } + engines: { node: ">=10" } + + lodash.merge@4.6.2: + resolution: + { + integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==, + } + + loupe@3.2.1: + resolution: + { + integrity: sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ==, + } + + magic-string@0.30.21: + resolution: + { + integrity: sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==, + } + + minimatch@10.2.6: + resolution: + { + integrity: sha512-vpLQEs+VLCr1nU0BXS07maYoFwlDAH0gngQuuttxIwutDFEMHq2blX+8vpgxDdK3J1PwjCJiep77OitTZ4Ll1A==, + } + engines: { node: 18 || 20 || >=22 } + + minimatch@3.1.5: + resolution: + { + integrity: sha512-VgjWUsnnT6n+NUk6eZq77zeFdpW2LWDzP6zFGrCbHXiYNul5Dzqk2HHQ5uFH2DNW5Xbp8+jVzaeNt94ssEEl4w==, + } + + ms@2.1.3: + resolution: + { + integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==, + } + + nanoid@3.3.16: + resolution: + { + integrity: sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==, + } + engines: { node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1 } + hasBin: true + + natural-compare@1.4.0: + resolution: + { + integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==, + } + + optionator@0.9.4: + resolution: + { + integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==, + } + engines: { node: ">= 0.8.0" } + + p-limit@3.1.0: + resolution: + { + integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==, + } + engines: { node: ">=10" } + + p-locate@5.0.0: + resolution: + { + integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==, + } + engines: { node: ">=10" } + + parent-module@1.0.1: + resolution: + { + integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==, + } + engines: { node: ">=6" } + + path-exists@4.0.0: + resolution: + { + integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==, + } + engines: { node: ">=8" } + + path-key@3.1.1: + resolution: + { + integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==, + } + engines: { node: ">=8" } + + pathe@2.0.3: + resolution: + { + integrity: sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==, + } + + pathval@2.0.1: + resolution: + { + integrity: sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ==, + } + engines: { node: ">= 14.16" } + + picocolors@1.1.1: + resolution: + { + integrity: sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==, + } + + picomatch@4.0.5: + resolution: + { + integrity: sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==, + } + engines: { node: ">=12" } + + postcss@8.5.25: + resolution: + { + integrity: sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==, + } + engines: { node: ^10 || ^12 || >=14 } + + prelude-ls@1.2.1: + resolution: + { + integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==, + } + engines: { node: ">= 0.8.0" } + + prettier@3.9.6: + resolution: + { + integrity: sha512-OpN0zzVdiaiAhxpuuj5efpIS4sY9j7bY6uR5mnj5yPzGkdkjNKSJeUThPb60Jw29QuAZgA4o+/iB49kFiaBX6g==, + } + engines: { node: ">=14" } + hasBin: true + + punycode@2.3.1: + resolution: + { + integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==, + } + engines: { node: ">=6" } + + pure-rand@8.4.2: + resolution: + { + integrity: sha512-vvuOGgcuPJAirlHvuQw1TrOiw7ptaIXXmIbNuiNOY6lNGJJH49PQ1Kj4nd783nPdQhQdicgOjVI2yI/9BD6/Ng==, + } + + resolve-from@4.0.0: + resolution: + { + integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==, + } + engines: { node: ">=4" } + + rollup@4.62.3: + resolution: + { + integrity: sha512-Gu0c0iH9FzgX1L1t7ByIbbS3Vmdz+6KHm/EsqmmC71gUQ82yvZRkTK6XzrFObSka91WUVdynqp6nsfilzr5k6Q==, + } + engines: { node: ">=18.0.0", npm: ">=8.0.0" } + hasBin: true + + semver@7.8.5: + resolution: + { + integrity: sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==, + } + engines: { node: ">=10" } + hasBin: true + + sharp@0.35.3: + resolution: + { + integrity: sha512-ej0zVHuZGHCiABXcNxeYhpRnPNPAcvbG8RMdBAhDAxLKkCRVSpK3Iyu7qbqw3JMzoj0REeM6f3tJLtVwl0023Q==, + } + engines: { node: ">=20.9.0" } + peerDependencies: + "@types/node": "*" + peerDependenciesMeta: + "@types/node": + optional: true + + shebang-command@2.0.0: + resolution: + { + integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==, + } + engines: { node: ">=8" } + + shebang-regex@3.0.0: + resolution: + { + integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==, + } + engines: { node: ">=8" } + + siginfo@2.0.0: + resolution: + { + integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==, + } + + source-map-js@1.2.1: + resolution: + { + integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==, + } + engines: { node: ">=0.10.0" } + + stackback@0.0.2: + resolution: + { + integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==, + } + + std-env@3.10.0: + resolution: + { + integrity: sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==, + } + + strip-json-comments@3.1.1: + resolution: + { + integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==, + } + engines: { node: ">=8" } + + strip-literal@3.1.0: + resolution: + { + integrity: sha512-8r3mkIM/2+PpjHoOtiAW8Rg3jJLHaV7xPwG+YRGrv6FP0wwk/toTpATxWYOW0BKdWwl82VT2tFYi5DlROa0Mxg==, + } + + supports-color@7.2.0: + resolution: + { + integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==, + } + engines: { node: ">=8" } + + tinybench@2.9.0: + resolution: + { + integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==, + } + + tinyexec@0.3.2: + resolution: + { + integrity: sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA==, + } + + tinyglobby@0.2.17: + resolution: + { + integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==, + } + engines: { node: ">=12.0.0" } + + tinypool@1.1.1: + resolution: + { + integrity: sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg==, + } + engines: { node: ^18.0.0 || >=20.0.0 } + + tinyrainbow@2.0.0: + resolution: + { + integrity: sha512-op4nsTR47R6p0vMUUoYl/a+ljLFVtlfaXkLQmqfLR1qHma1h/ysYk4hEXZ880bf2CYgTskvTa/e196Vd5dDQXw==, + } + engines: { node: ">=14.0.0" } + + tinyspy@4.0.4: + resolution: + { + integrity: sha512-azl+t0z7pw/z958Gy9svOTuzqIk6xq+NSheJzn5MMWtWTFywIacg2wUlzKFGtt3cthx0r2SxMK0yzJOR0IES7Q==, + } + engines: { node: ">=14.0.0" } + + ts-api-utils@2.5.0: + resolution: + { + integrity: sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==, + } + engines: { node: ">=18.12" } + peerDependencies: + typescript: ">=4.8.4" + + tslib@2.8.1: + resolution: + { + integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==, + } + + type-check@0.4.0: + resolution: + { + integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==, + } + engines: { node: ">= 0.8.0" } + + typescript@5.9.3: + resolution: + { + integrity: sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==, + } + engines: { node: ">=14.17" } + hasBin: true + + undici-types@6.21.0: + resolution: + { + integrity: sha512-iwDZqg0QAGrg9Rav5H4n0M64c3mkR59cJ6wQp+7C4nI0gsmExaedaYLNO44eT4AtBBwjbTiGPMlt2Md0T9H9JQ==, + } + + uri-js@4.4.1: + resolution: + { + integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==, + } + + vite-node@3.2.4: + resolution: + { + integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==, + } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + hasBin: true + + vite@7.3.6: + resolution: + { + integrity: sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg==, + } + engines: { node: ^20.19.0 || >=22.12.0 } + hasBin: true + peerDependencies: + "@types/node": ^20.19.0 || >=22.12.0 + jiti: ">=1.21.0" + less: ^4.0.0 + lightningcss: ^1.21.0 + sass: ^1.70.0 + sass-embedded: ^1.70.0 + stylus: ">=0.54.8" + sugarss: ^5.0.0 + terser: ^5.16.0 + tsx: ^4.8.1 + yaml: ^2.4.2 + peerDependenciesMeta: + "@types/node": + optional: true + jiti: + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + tsx: + optional: true + yaml: + optional: true + + vitest@3.2.7: + resolution: + { + integrity: sha512-KrxIJ62Fd89gfysR4WotlgZABiz2dqFPgqGzX7s+CwsqLFomRH7777ZcrOD6+WVAh7khPQP41A+BKbpcJFrdEg==, + } + engines: { node: ^18.0.0 || ^20.0.0 || >=22.0.0 } + hasBin: true + peerDependencies: + "@edge-runtime/vm": "*" + "@types/debug": ^4.1.12 + "@types/node": ^18.0.0 || ^20.0.0 || >=22.0.0 + "@vitest/browser": 3.2.7 + "@vitest/ui": 3.2.7 + happy-dom: "*" + jsdom: "*" + peerDependenciesMeta: + "@edge-runtime/vm": + optional: true + "@types/debug": + optional: true + "@types/node": + optional: true + "@vitest/browser": + optional: true + "@vitest/ui": + optional: true + happy-dom: + optional: true + jsdom: + optional: true + + which@2.0.2: + resolution: + { + integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==, + } + engines: { node: ">= 8" } + hasBin: true + + why-is-node-running@2.3.0: + resolution: + { + integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==, + } + engines: { node: ">=8" } + hasBin: true + + word-wrap@1.2.5: + resolution: + { + integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==, + } + engines: { node: ">=0.10.0" } + + yocto-queue@0.1.0: + resolution: + { + integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==, + } + engines: { node: ">=10" } + +snapshots: + "@emnapi/runtime@1.11.3": + dependencies: + tslib: 2.8.1 + optional: true + + "@esbuild/aix-ppc64@0.28.1": + optional: true + + "@esbuild/android-arm64@0.28.1": + optional: true + + "@esbuild/android-arm@0.28.1": + optional: true + + "@esbuild/android-x64@0.28.1": + optional: true + + "@esbuild/darwin-arm64@0.28.1": + optional: true + + "@esbuild/darwin-x64@0.28.1": + optional: true + + "@esbuild/freebsd-arm64@0.28.1": + optional: true + + "@esbuild/freebsd-x64@0.28.1": + optional: true + + "@esbuild/linux-arm64@0.28.1": + optional: true + + "@esbuild/linux-arm@0.28.1": + optional: true + + "@esbuild/linux-ia32@0.28.1": + optional: true + + "@esbuild/linux-loong64@0.28.1": + optional: true + + "@esbuild/linux-mips64el@0.28.1": + optional: true + + "@esbuild/linux-ppc64@0.28.1": + optional: true + + "@esbuild/linux-riscv64@0.28.1": + optional: true + + "@esbuild/linux-s390x@0.28.1": + optional: true + + "@esbuild/linux-x64@0.28.1": + optional: true + + "@esbuild/netbsd-arm64@0.28.1": + optional: true + + "@esbuild/netbsd-x64@0.28.1": + optional: true + + "@esbuild/openbsd-arm64@0.28.1": + optional: true + + "@esbuild/openbsd-x64@0.28.1": + optional: true + + "@esbuild/openharmony-arm64@0.28.1": + optional: true + + "@esbuild/sunos-x64@0.28.1": + optional: true + + "@esbuild/win32-arm64@0.28.1": + optional: true + + "@esbuild/win32-ia32@0.28.1": + optional: true + + "@esbuild/win32-x64@0.28.1": + optional: true + + "@eslint-community/eslint-utils@4.10.1(eslint@9.39.5)": + dependencies: + eslint: 9.39.5 + eslint-visitor-keys: 3.4.3 + + "@eslint-community/regexpp@4.12.2": {} + + "@eslint/config-array@0.21.2": + dependencies: + "@eslint/object-schema": 2.1.7 + debug: 4.4.3 + minimatch: 3.1.5 + transitivePeerDependencies: + - supports-color + + "@eslint/config-helpers@0.4.2": + dependencies: + "@eslint/core": 0.17.0 + + "@eslint/core@0.17.0": + dependencies: + "@types/json-schema": 7.0.15 + + "@eslint/eslintrc@3.3.6": + dependencies: + ajv: 6.15.0 + debug: 4.4.3 + espree: 10.4.0 + globals: 14.0.0 + ignore: 5.3.2 + import-fresh: 3.3.1 + js-yaml: 4.3.0 + minimatch: 3.1.5 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + + "@eslint/js@9.39.5": {} + + "@eslint/object-schema@2.1.7": {} + + "@eslint/plugin-kit@0.4.1": + dependencies: + "@eslint/core": 0.17.0 + levn: 0.4.1 + + "@humanfs/core@0.19.2": + dependencies: + "@humanfs/types": 0.15.0 + + "@humanfs/node@0.16.8": + dependencies: + "@humanfs/core": 0.19.2 + "@humanfs/types": 0.15.0 + "@humanwhocodes/retry": 0.4.3 + + "@humanfs/types@0.15.0": {} + + "@humanwhocodes/module-importer@1.0.1": {} + + "@humanwhocodes/retry@0.4.3": {} + + "@img/colour@1.1.0": {} + + "@img/sharp-darwin-arm64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-darwin-arm64": 1.3.2 + optional: true + + "@img/sharp-darwin-x64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-darwin-x64": 1.3.2 + optional: true + + "@img/sharp-freebsd-wasm32@0.35.3": + dependencies: + "@img/sharp-wasm32": 0.35.3 + optional: true + + "@img/sharp-libvips-darwin-arm64@1.3.2": + optional: true + + "@img/sharp-libvips-darwin-x64@1.3.2": + optional: true + + "@img/sharp-libvips-linux-arm64@1.3.2": + optional: true + + "@img/sharp-libvips-linux-arm@1.3.2": + optional: true + + "@img/sharp-libvips-linux-ppc64@1.3.2": + optional: true + + "@img/sharp-libvips-linux-riscv64@1.3.2": + optional: true + + "@img/sharp-libvips-linux-s390x@1.3.2": + optional: true + + "@img/sharp-libvips-linux-x64@1.3.2": + optional: true + + "@img/sharp-libvips-linuxmusl-arm64@1.3.2": + optional: true + + "@img/sharp-libvips-linuxmusl-x64@1.3.2": + optional: true + + "@img/sharp-linux-arm64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-arm64": 1.3.2 + optional: true + + "@img/sharp-linux-arm@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-arm": 1.3.2 + optional: true + + "@img/sharp-linux-ppc64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-ppc64": 1.3.2 + optional: true + + "@img/sharp-linux-riscv64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-riscv64": 1.3.2 + optional: true + + "@img/sharp-linux-s390x@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-s390x": 1.3.2 + optional: true + + "@img/sharp-linux-x64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linux-x64": 1.3.2 + optional: true + + "@img/sharp-linuxmusl-arm64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-arm64": 1.3.2 + optional: true + + "@img/sharp-linuxmusl-x64@0.35.3": + optionalDependencies: + "@img/sharp-libvips-linuxmusl-x64": 1.3.2 + optional: true + + "@img/sharp-wasm32@0.35.3": + dependencies: + "@emnapi/runtime": 1.11.3 + optional: true + + "@img/sharp-webcontainers-wasm32@0.35.3": + dependencies: + "@img/sharp-wasm32": 0.35.3 + optional: true + + "@img/sharp-win32-arm64@0.35.3": + optional: true + + "@img/sharp-win32-ia32@0.35.3": + optional: true + + "@img/sharp-win32-x64@0.35.3": + optional: true + + "@jridgewell/sourcemap-codec@1.5.5": {} + + "@rollup/rollup-android-arm-eabi@4.62.3": + optional: true + + "@rollup/rollup-android-arm64@4.62.3": + optional: true + + "@rollup/rollup-darwin-arm64@4.62.3": + optional: true + + "@rollup/rollup-darwin-x64@4.62.3": + optional: true + + "@rollup/rollup-freebsd-arm64@4.62.3": + optional: true + + "@rollup/rollup-freebsd-x64@4.62.3": + optional: true + + "@rollup/rollup-linux-arm-gnueabihf@4.62.3": + optional: true + + "@rollup/rollup-linux-arm-musleabihf@4.62.3": + optional: true + + "@rollup/rollup-linux-arm64-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-arm64-musl@4.62.3": + optional: true + + "@rollup/rollup-linux-loong64-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-loong64-musl@4.62.3": + optional: true + + "@rollup/rollup-linux-ppc64-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-ppc64-musl@4.62.3": + optional: true + + "@rollup/rollup-linux-riscv64-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-riscv64-musl@4.62.3": + optional: true + + "@rollup/rollup-linux-s390x-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-x64-gnu@4.62.3": + optional: true + + "@rollup/rollup-linux-x64-musl@4.62.3": + optional: true + + "@rollup/rollup-openbsd-x64@4.62.3": + optional: true + + "@rollup/rollup-openharmony-arm64@4.62.3": + optional: true + + "@rollup/rollup-win32-arm64-msvc@4.62.3": + optional: true + + "@rollup/rollup-win32-ia32-msvc@4.62.3": + optional: true + + "@rollup/rollup-win32-x64-gnu@4.62.3": + optional: true + + "@rollup/rollup-win32-x64-msvc@4.62.3": + optional: true + + "@types/chai@5.2.3": + dependencies: + "@types/deep-eql": 4.0.2 + assertion-error: 2.0.1 + + "@types/deep-eql@4.0.2": {} + + "@types/estree@1.0.9": {} + + "@types/json-schema@7.0.15": {} + + "@types/node@22.20.1": + dependencies: + undici-types: 6.21.0 + + "@typescript-eslint/eslint-plugin@8.65.0(@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3))(eslint@9.39.5)(typescript@5.9.3)": + dependencies: + "@eslint-community/regexpp": 4.12.2 + "@typescript-eslint/parser": 8.65.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/scope-manager": 8.65.0 + "@typescript-eslint/type-utils": 8.65.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/utils": 8.65.0(eslint@9.39.5)(typescript@5.9.3) + "@typescript-eslint/visitor-keys": 8.65.0 + eslint: 9.39.5 + ignore: 7.0.6 + natural-compare: 1.4.0 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/parser@8.65.0(eslint@9.39.5)(typescript@5.9.3)": + dependencies: + "@typescript-eslint/scope-manager": 8.65.0 + "@typescript-eslint/types": 8.65.0 + "@typescript-eslint/typescript-estree": 8.65.0(typescript@5.9.3) + "@typescript-eslint/visitor-keys": 8.65.0 + debug: 4.4.3 + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/project-service@8.65.0(typescript@5.9.3)": + dependencies: + "@typescript-eslint/tsconfig-utils": 8.65.0(typescript@5.9.3) + "@typescript-eslint/types": 8.65.0 + debug: 4.4.3 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/scope-manager@8.65.0": + dependencies: + "@typescript-eslint/types": 8.65.0 + "@typescript-eslint/visitor-keys": 8.65.0 + + "@typescript-eslint/tsconfig-utils@8.65.0(typescript@5.9.3)": + dependencies: + typescript: 5.9.3 + + "@typescript-eslint/type-utils@8.65.0(eslint@9.39.5)(typescript@5.9.3)": + dependencies: + "@typescript-eslint/types": 8.65.0 + "@typescript-eslint/typescript-estree": 8.65.0(typescript@5.9.3) + "@typescript-eslint/utils": 8.65.0(eslint@9.39.5)(typescript@5.9.3) + debug: 4.4.3 + eslint: 9.39.5 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/types@8.65.0": {} + + "@typescript-eslint/typescript-estree@8.65.0(typescript@5.9.3)": + dependencies: + "@typescript-eslint/project-service": 8.65.0(typescript@5.9.3) + "@typescript-eslint/tsconfig-utils": 8.65.0(typescript@5.9.3) + "@typescript-eslint/types": 8.65.0 + "@typescript-eslint/visitor-keys": 8.65.0 + debug: 4.4.3 + minimatch: 10.2.6 + semver: 7.8.5 + tinyglobby: 0.2.17 + ts-api-utils: 2.5.0(typescript@5.9.3) + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/utils@8.65.0(eslint@9.39.5)(typescript@5.9.3)": + dependencies: + "@eslint-community/eslint-utils": 4.10.1(eslint@9.39.5) + "@typescript-eslint/scope-manager": 8.65.0 + "@typescript-eslint/types": 8.65.0 + "@typescript-eslint/typescript-estree": 8.65.0(typescript@5.9.3) + eslint: 9.39.5 + typescript: 5.9.3 + transitivePeerDependencies: + - supports-color + + "@typescript-eslint/visitor-keys@8.65.0": + dependencies: + "@typescript-eslint/types": 8.65.0 + eslint-visitor-keys: 5.0.1 + + "@vitest/expect@3.2.7": + dependencies: + "@types/chai": 5.2.3 + "@vitest/spy": 3.2.7 + "@vitest/utils": 3.2.7 + chai: 5.3.3 + tinyrainbow: 2.0.0 + + "@vitest/mocker@3.2.7(vite@7.3.6(@types/node@22.20.1))": + dependencies: + "@vitest/spy": 3.2.7 + estree-walker: 3.0.3 + magic-string: 0.30.21 + optionalDependencies: + vite: 7.3.6(@types/node@22.20.1) + + "@vitest/pretty-format@3.2.7": + dependencies: + tinyrainbow: 2.0.0 + + "@vitest/runner@3.2.7": + dependencies: + "@vitest/utils": 3.2.7 + pathe: 2.0.3 + strip-literal: 3.1.0 + + "@vitest/snapshot@3.2.7": + dependencies: + "@vitest/pretty-format": 3.2.7 + magic-string: 0.30.21 + pathe: 2.0.3 + + "@vitest/spy@3.2.7": + dependencies: + tinyspy: 4.0.4 + + "@vitest/utils@3.2.7": + dependencies: + "@vitest/pretty-format": 3.2.7 + loupe: 3.2.1 + tinyrainbow: 2.0.0 + + acorn-jsx@5.3.2(acorn@8.18.0): + dependencies: + acorn: 8.18.0 + + acorn@8.18.0: {} + + ajv@6.15.0: + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + + ansi-styles@4.3.0: + dependencies: + color-convert: 2.0.1 + + argparse@2.0.1: {} + + assertion-error@2.0.1: {} + + balanced-match@1.0.2: {} + + balanced-match@4.0.4: {} + + brace-expansion@1.1.18: + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + + brace-expansion@5.0.9: + dependencies: + balanced-match: 4.0.4 + + cac@6.7.14: {} + + callsites@3.1.0: {} + + chai@5.3.3: + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.3 + deep-eql: 5.0.2 + loupe: 3.2.1 + pathval: 2.0.1 + + chalk@4.1.2: + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + + check-error@2.1.3: {} + + color-convert@2.0.1: + dependencies: + color-name: 1.1.4 + + color-name@1.1.4: {} + + concat-map@0.0.1: {} + + cross-spawn@7.0.6: + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + + debug@4.4.3: + dependencies: + ms: 2.1.3 + + deep-eql@5.0.2: {} + + deep-is@0.1.4: {} + + detect-libc@2.1.2: {} + + es-module-lexer@1.7.0: {} + + esbuild@0.28.1: + optionalDependencies: + "@esbuild/aix-ppc64": 0.28.1 + "@esbuild/android-arm": 0.28.1 + "@esbuild/android-arm64": 0.28.1 + "@esbuild/android-x64": 0.28.1 + "@esbuild/darwin-arm64": 0.28.1 + "@esbuild/darwin-x64": 0.28.1 + "@esbuild/freebsd-arm64": 0.28.1 + "@esbuild/freebsd-x64": 0.28.1 + "@esbuild/linux-arm": 0.28.1 + "@esbuild/linux-arm64": 0.28.1 + "@esbuild/linux-ia32": 0.28.1 + "@esbuild/linux-loong64": 0.28.1 + "@esbuild/linux-mips64el": 0.28.1 + "@esbuild/linux-ppc64": 0.28.1 + "@esbuild/linux-riscv64": 0.28.1 + "@esbuild/linux-s390x": 0.28.1 + "@esbuild/linux-x64": 0.28.1 + "@esbuild/netbsd-arm64": 0.28.1 + "@esbuild/netbsd-x64": 0.28.1 + "@esbuild/openbsd-arm64": 0.28.1 + "@esbuild/openbsd-x64": 0.28.1 + "@esbuild/openharmony-arm64": 0.28.1 + "@esbuild/sunos-x64": 0.28.1 + "@esbuild/win32-arm64": 0.28.1 + "@esbuild/win32-ia32": 0.28.1 + "@esbuild/win32-x64": 0.28.1 + + escape-string-regexp@4.0.0: {} + + eslint-scope@8.4.0: + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + + eslint-visitor-keys@3.4.3: {} + + eslint-visitor-keys@4.2.1: {} + + eslint-visitor-keys@5.0.1: {} + + eslint@9.39.5: + dependencies: + "@eslint-community/eslint-utils": 4.10.1(eslint@9.39.5) + "@eslint-community/regexpp": 4.12.2 + "@eslint/config-array": 0.21.2 + "@eslint/config-helpers": 0.4.2 + "@eslint/core": 0.17.0 + "@eslint/eslintrc": 3.3.6 + "@eslint/js": 9.39.5 + "@eslint/plugin-kit": 0.4.1 + "@humanfs/node": 0.16.8 + "@humanwhocodes/module-importer": 1.0.1 + "@humanwhocodes/retry": 0.4.3 + "@types/estree": 1.0.9 + ajv: 6.15.0 + chalk: 4.1.2 + cross-spawn: 7.0.6 + debug: 4.4.3 + escape-string-regexp: 4.0.0 + eslint-scope: 8.4.0 + eslint-visitor-keys: 4.2.1 + espree: 10.4.0 + esquery: 1.7.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 8.0.0 + find-up: 5.0.0 + glob-parent: 6.0.2 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + json-stable-stringify-without-jsonify: 1.0.1 + lodash.merge: 4.6.2 + minimatch: 3.1.5 + natural-compare: 1.4.0 + optionator: 0.9.4 + transitivePeerDependencies: + - supports-color + + espree@10.4.0: + dependencies: + acorn: 8.18.0 + acorn-jsx: 5.3.2(acorn@8.18.0) + eslint-visitor-keys: 4.2.1 + + esquery@1.7.0: + dependencies: + estraverse: 5.3.0 + + esrecurse@4.3.0: + dependencies: + estraverse: 5.3.0 + + estraverse@5.3.0: {} + + estree-walker@3.0.3: + dependencies: + "@types/estree": 1.0.9 + + esutils@2.0.3: {} + + expect-type@1.4.0: {} + + fast-check@4.9.0: + dependencies: + pure-rand: 8.4.2 + + fast-deep-equal@3.1.3: {} + + fast-json-stable-stringify@2.1.0: {} + + fast-levenshtein@2.0.6: {} + + fdir@6.5.0(picomatch@4.0.5): + optionalDependencies: + picomatch: 4.0.5 + + file-entry-cache@8.0.0: + dependencies: + flat-cache: 4.0.1 + + find-up@5.0.0: + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + + flat-cache@4.0.1: + dependencies: + flatted: 3.4.4 + keyv: 4.5.4 + + flatted@3.4.4: {} + + fsevents@2.3.3: + optional: true + + glob-parent@6.0.2: + dependencies: + is-glob: 4.0.3 + + globals@14.0.0: {} + + globals@16.5.0: {} + + has-flag@4.0.0: {} + + ignore@5.3.2: {} + + ignore@7.0.6: {} + + import-fresh@3.3.1: + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + imurmurhash@0.1.4: {} + + is-extglob@2.1.1: {} + + is-glob@4.0.3: + dependencies: + is-extglob: 2.1.1 + + isexe@2.0.0: {} + + js-tokens@9.0.1: {} + + js-yaml@4.3.0: + dependencies: + argparse: 2.0.1 + + json-buffer@3.0.1: {} + + json-schema-traverse@0.4.1: {} + + json-stable-stringify-without-jsonify@1.0.1: {} + + keyv@4.5.4: + dependencies: + json-buffer: 3.0.1 + + levn@0.4.1: + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + + locate-path@6.0.0: + dependencies: + p-locate: 5.0.0 + + lodash.merge@4.6.2: {} + + loupe@3.2.1: {} + + magic-string@0.30.21: + dependencies: + "@jridgewell/sourcemap-codec": 1.5.5 + + minimatch@10.2.6: + dependencies: + brace-expansion: 5.0.9 + + minimatch@3.1.5: + dependencies: + brace-expansion: 1.1.18 + + ms@2.1.3: {} + + nanoid@3.3.16: {} + + natural-compare@1.4.0: {} + + optionator@0.9.4: + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + + p-limit@3.1.0: + dependencies: + yocto-queue: 0.1.0 + + p-locate@5.0.0: + dependencies: + p-limit: 3.1.0 + + parent-module@1.0.1: + dependencies: + callsites: 3.1.0 + + path-exists@4.0.0: {} + + path-key@3.1.1: {} + + pathe@2.0.3: {} + + pathval@2.0.1: {} + + picocolors@1.1.1: {} + + picomatch@4.0.5: {} + + postcss@8.5.25: + dependencies: + nanoid: 3.3.16 + picocolors: 1.1.1 + source-map-js: 1.2.1 + + prelude-ls@1.2.1: {} + + prettier@3.9.6: {} + + punycode@2.3.1: {} + + pure-rand@8.4.2: {} + + resolve-from@4.0.0: {} + + rollup@4.62.3: + dependencies: + "@types/estree": 1.0.9 + optionalDependencies: + "@rollup/rollup-android-arm-eabi": 4.62.3 + "@rollup/rollup-android-arm64": 4.62.3 + "@rollup/rollup-darwin-arm64": 4.62.3 + "@rollup/rollup-darwin-x64": 4.62.3 + "@rollup/rollup-freebsd-arm64": 4.62.3 + "@rollup/rollup-freebsd-x64": 4.62.3 + "@rollup/rollup-linux-arm-gnueabihf": 4.62.3 + "@rollup/rollup-linux-arm-musleabihf": 4.62.3 + "@rollup/rollup-linux-arm64-gnu": 4.62.3 + "@rollup/rollup-linux-arm64-musl": 4.62.3 + "@rollup/rollup-linux-loong64-gnu": 4.62.3 + "@rollup/rollup-linux-loong64-musl": 4.62.3 + "@rollup/rollup-linux-ppc64-gnu": 4.62.3 + "@rollup/rollup-linux-ppc64-musl": 4.62.3 + "@rollup/rollup-linux-riscv64-gnu": 4.62.3 + "@rollup/rollup-linux-riscv64-musl": 4.62.3 + "@rollup/rollup-linux-s390x-gnu": 4.62.3 + "@rollup/rollup-linux-x64-gnu": 4.62.3 + "@rollup/rollup-linux-x64-musl": 4.62.3 + "@rollup/rollup-openbsd-x64": 4.62.3 + "@rollup/rollup-openharmony-arm64": 4.62.3 + "@rollup/rollup-win32-arm64-msvc": 4.62.3 + "@rollup/rollup-win32-ia32-msvc": 4.62.3 + "@rollup/rollup-win32-x64-gnu": 4.62.3 + "@rollup/rollup-win32-x64-msvc": 4.62.3 + fsevents: 2.3.3 + + semver@7.8.5: {} + + sharp@0.35.3(@types/node@22.20.1): + dependencies: + "@img/colour": 1.1.0 + detect-libc: 2.1.2 + semver: 7.8.5 + optionalDependencies: + "@img/sharp-darwin-arm64": 0.35.3 + "@img/sharp-darwin-x64": 0.35.3 + "@img/sharp-freebsd-wasm32": 0.35.3 + "@img/sharp-libvips-darwin-arm64": 1.3.2 + "@img/sharp-libvips-darwin-x64": 1.3.2 + "@img/sharp-libvips-linux-arm": 1.3.2 + "@img/sharp-libvips-linux-arm64": 1.3.2 + "@img/sharp-libvips-linux-ppc64": 1.3.2 + "@img/sharp-libvips-linux-riscv64": 1.3.2 + "@img/sharp-libvips-linux-s390x": 1.3.2 + "@img/sharp-libvips-linux-x64": 1.3.2 + "@img/sharp-libvips-linuxmusl-arm64": 1.3.2 + "@img/sharp-libvips-linuxmusl-x64": 1.3.2 + "@img/sharp-linux-arm": 0.35.3 + "@img/sharp-linux-arm64": 0.35.3 + "@img/sharp-linux-ppc64": 0.35.3 + "@img/sharp-linux-riscv64": 0.35.3 + "@img/sharp-linux-s390x": 0.35.3 + "@img/sharp-linux-x64": 0.35.3 + "@img/sharp-linuxmusl-arm64": 0.35.3 + "@img/sharp-linuxmusl-x64": 0.35.3 + "@img/sharp-webcontainers-wasm32": 0.35.3 + "@img/sharp-win32-arm64": 0.35.3 + "@img/sharp-win32-ia32": 0.35.3 + "@img/sharp-win32-x64": 0.35.3 + "@types/node": 22.20.1 + + shebang-command@2.0.0: + dependencies: + shebang-regex: 3.0.0 + + shebang-regex@3.0.0: {} + + siginfo@2.0.0: {} + + source-map-js@1.2.1: {} + + stackback@0.0.2: {} + + std-env@3.10.0: {} + + strip-json-comments@3.1.1: {} + + strip-literal@3.1.0: + dependencies: + js-tokens: 9.0.1 + + supports-color@7.2.0: + dependencies: + has-flag: 4.0.0 + + tinybench@2.9.0: {} + + tinyexec@0.3.2: {} + + tinyglobby@0.2.17: + dependencies: + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + + tinypool@1.1.1: {} + + tinyrainbow@2.0.0: {} + + tinyspy@4.0.4: {} + + ts-api-utils@2.5.0(typescript@5.9.3): + dependencies: + typescript: 5.9.3 + + tslib@2.8.1: + optional: true + + type-check@0.4.0: + dependencies: + prelude-ls: 1.2.1 + + typescript@5.9.3: {} + + undici-types@6.21.0: {} + + uri-js@4.4.1: + dependencies: + punycode: 2.3.1 + + vite-node@3.2.4(@types/node@22.20.1): + dependencies: + cac: 6.7.14 + debug: 4.4.3 + es-module-lexer: 1.7.0 + pathe: 2.0.3 + vite: 7.3.6(@types/node@22.20.1) + transitivePeerDependencies: + - "@types/node" + - jiti + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + vite@7.3.6(@types/node@22.20.1): + dependencies: + esbuild: 0.28.1 + fdir: 6.5.0(picomatch@4.0.5) + picomatch: 4.0.5 + postcss: 8.5.25 + rollup: 4.62.3 + tinyglobby: 0.2.17 + optionalDependencies: + "@types/node": 22.20.1 + fsevents: 2.3.3 + + vitest@3.2.7(@types/node@22.20.1): + dependencies: + "@types/chai": 5.2.3 + "@vitest/expect": 3.2.7 + "@vitest/mocker": 3.2.7(vite@7.3.6(@types/node@22.20.1)) + "@vitest/pretty-format": 3.2.7 + "@vitest/runner": 3.2.7 + "@vitest/snapshot": 3.2.7 + "@vitest/spy": 3.2.7 + "@vitest/utils": 3.2.7 + chai: 5.3.3 + debug: 4.4.3 + expect-type: 1.4.0 + magic-string: 0.30.21 + pathe: 2.0.3 + picomatch: 4.0.5 + std-env: 3.10.0 + tinybench: 2.9.0 + tinyexec: 0.3.2 + tinyglobby: 0.2.17 + tinypool: 1.1.1 + tinyrainbow: 2.0.0 + vite: 7.3.6(@types/node@22.20.1) + vite-node: 3.2.4(@types/node@22.20.1) + why-is-node-running: 2.3.0 + optionalDependencies: + "@types/node": 22.20.1 + transitivePeerDependencies: + - jiti + - less + - lightningcss + - msw + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + - tsx + - yaml + + which@2.0.2: + dependencies: + isexe: 2.0.0 + + why-is-node-running@2.3.0: + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + + word-wrap@1.2.5: {} + + yocto-queue@0.1.0: {} diff --git a/pnpm-workspace.yaml b/pnpm-workspace.yaml new file mode 100644 index 0000000..e306bb5 --- /dev/null +++ b/pnpm-workspace.yaml @@ -0,0 +1,5 @@ +packages: + - "." +onlyBuiltDependencies: + - esbuild + - sharp diff --git a/src/cleanup/alpha-thresholding.ts b/src/cleanup/alpha-thresholding.ts new file mode 100644 index 0000000..2f368a8 --- /dev/null +++ b/src/cleanup/alpha-thresholding.ts @@ -0,0 +1,87 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import { CleanupError } from "./cleanup-errors.js"; +import type { AlphaThresholdOptions } from "./cleanup-types.js"; + +/** + * Apply deterministic alpha thresholding. + * + * For every pixel: + * - alpha < alphaLow -> 0 + * - alpha > alphaHigh -> 1 + * - otherwise -> unchanged + * + * The input alpha channel is not mutated. + * + * @param alpha - The alpha channel to threshold. + * @param options - The lower and upper threshold values. + * @returns A new alpha channel with thresholded values. + * @throws CleanupError when the configuration is invalid. + */ +export function applyAlphaThresholding( + alpha: AlphaChannelData, + options: AlphaThresholdOptions, +): AlphaChannelData { + const { alphaLow, alphaHigh } = options; + + validateThresholds(alphaLow, alphaHigh); + validateAlphaChannel(alpha); + + const pixelCount = alpha.width * alpha.height; + const output = new Float32Array(pixelCount); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const value = alpha.data[pixel]; + if (value < alphaLow) { + output[pixel] = 0; + } else if (value > alphaHigh) { + output[pixel] = 1; + } else { + output[pixel] = value; + } + } + + return { + width: alpha.width, + height: alpha.height, + channels: 1, + format: "alpha", + data: output, + }; +} + +function validateThresholds(alphaLow: number, alphaHigh: number): void { + if (!Number.isFinite(alphaLow) || !Number.isFinite(alphaHigh)) { + throw new CleanupError("Threshold values must be finite numbers."); + } + + if (alphaLow < 0 || alphaHigh > 1) { + throw new CleanupError("Threshold values must be within [0, 1]."); + } + + if (alphaLow > alphaHigh) { + throw new CleanupError(`alphaLow (${alphaLow}) must not exceed alphaHigh (${alphaHigh}).`); + } +} + +function validateAlphaChannel(alpha: AlphaChannelData): void { + if (alpha.format !== "alpha" || alpha.channels !== 1) { + throw new CleanupError( + `Alpha channel must have format "alpha" and 1 channel, got format "${alpha.format}" and ${alpha.channels} channels.`, + ); + } + + if (!Number.isInteger(alpha.width) || alpha.width <= 0) { + throw new CleanupError(`Alpha channel width must be a positive integer, got ${alpha.width}.`); + } + + if (!Number.isInteger(alpha.height) || alpha.height <= 0) { + throw new CleanupError(`Alpha channel height must be a positive integer, got ${alpha.height}.`); + } + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new CleanupError( + `Alpha channel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } +} diff --git a/src/cleanup/cleanup-errors.ts b/src/cleanup/cleanup-errors.ts new file mode 100644 index 0000000..7315039 --- /dev/null +++ b/src/cleanup/cleanup-errors.ts @@ -0,0 +1,20 @@ +/** + * Error thrown when cleanup cannot be performed. + * + * This includes invalid inputs, incompatible dimensions, and invalid + * cleanup configuration. + */ +export class CleanupError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + } = {}, + ) { + super(message); + this.name = "CleanupError"; + this.cause = options.cause; + } + + readonly cause?: unknown; +} diff --git a/src/cleanup/cleanup-types.ts b/src/cleanup/cleanup-types.ts new file mode 100644 index 0000000..005b903 --- /dev/null +++ b/src/cleanup/cleanup-types.ts @@ -0,0 +1,130 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import type { ForegroundImageData } from "../reconstruction/index.js"; + +/** + * Options for the alpha thresholding cleanup stage. + */ +export interface AlphaThresholdOptions { + /** + * Pixels with alpha strictly below this value become fully transparent. + */ + readonly alphaLow: number; + + /** + * Pixels with alpha strictly above this value become fully opaque. + */ + readonly alphaHigh: number; +} + +/** + * Connectivity model for connected-component analysis. + */ +export type Connectivity = 4 | 8; + +/** + * Options for the noise removal cleanup stage. + */ +export interface NoiseRemovalOptions { + /** + * Connected components of foreground alpha smaller than this size (in pixels) + * are removed. + */ + readonly maxArtifactSize: number; + + /** + * Pixel connectivity for component analysis. Defaults to 4. + */ + readonly connectivity?: Connectivity; +} + +/** + * Supported structuring element shapes. + */ +export type StructuringElementShape = "square" | "disk" | "cross"; + +/** + * Options for a single morphological operation. + */ +export interface StructuringElementOptions { + /** + * Shape of the structuring element. + */ + readonly shape: StructuringElementShape; + + /** + * Radius of the structuring element in pixels. + */ + readonly radius: number; +} + +/** + * Options for the morphology cleanup stage. + * + * Opening is performed before closing. Either operation may be omitted by + * leaving its option undefined. + */ +export interface MorphologyOptions { + /** + * Optional morphological opening stage. + */ + readonly opening?: StructuringElementOptions; + + /** + * Optional morphological closing stage. + */ + readonly closing?: StructuringElementOptions; +} + +/** + * Options for the deterministic cleanup pipeline. + * + * The pipeline order is fixed and always runs: + * + * 1. Alpha thresholding + * 2. Noise removal + * 3. Morphological cleanup + * + * A stage is enabled only when its corresponding options are provided. + * The optional foreground image is passed through unchanged. + */ +export interface CleanupOptions { + /** + * Alpha channel to clean. + */ + readonly alpha: AlphaChannelData; + + /** + * Optional foreground image. When provided, it is returned unchanged. + */ + readonly foreground?: ForegroundImageData; + + /** + * Optional alpha thresholding stage. + */ + readonly threshold?: AlphaThresholdOptions; + + /** + * Optional noise removal stage. + */ + readonly noiseRemoval?: NoiseRemovalOptions; + + /** + * Optional morphological cleanup stage. + */ + readonly morphology?: MorphologyOptions; +} + +/** + * Result of the deterministic cleanup pipeline. + */ +export interface CleanupResult { + /** + * Cleaned alpha channel. + */ + readonly alpha: AlphaChannelData; + + /** + * The foreground image, if it was provided, passed through unchanged. + */ + readonly foreground?: ForegroundImageData; +} diff --git a/src/cleanup/cleanup.ts b/src/cleanup/cleanup.ts new file mode 100644 index 0000000..b2787c9 --- /dev/null +++ b/src/cleanup/cleanup.ts @@ -0,0 +1,140 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import type { ForegroundImageData } from "../reconstruction/index.js"; +import { applyAlphaThresholding } from "./alpha-thresholding.js"; +import { CleanupError } from "./cleanup-errors.js"; +import type { CleanupOptions, CleanupResult } from "./cleanup-types.js"; +import { applyNoiseRemoval } from "./noise-removal.js"; +import { applyMorphologicalClosing, applyMorphologicalOpening } from "./morphology.js"; + +/** + * Run the deterministic cleanup pipeline. + * + * The pipeline order is fixed: + * + * 1. Alpha thresholding (optional) + * 2. Noise removal (optional) + * 3. Morphological cleanup (optional) + * + * Within morphology, opening is performed before closing when both are + * configured. The optional foreground image is passed through unchanged. + * + * @param options - Alpha channel, optional foreground, and stage configuration. + * @returns The cleaned alpha channel and the unchanged foreground image. + * @throws CleanupError when inputs are invalid or configuration is invalid. + */ +export function cleanup(options: CleanupOptions): CleanupResult { + const { alpha, foreground } = options; + + validateAlphaChannel(alpha); + + if (foreground !== undefined) { + validateForegroundImage(foreground, alpha); + } + + let currentAlpha: AlphaChannelData = copyAlphaChannel(alpha); + + if (options.threshold !== undefined) { + currentAlpha = applyAlphaThresholding(currentAlpha, options.threshold); + } + + if (options.noiseRemoval !== undefined) { + currentAlpha = applyNoiseRemoval(currentAlpha, options.noiseRemoval); + } + + if (options.morphology !== undefined) { + if (options.morphology.opening !== undefined) { + currentAlpha = applyMorphologicalOpening(currentAlpha, options.morphology.opening); + } + if (options.morphology.closing !== undefined) { + currentAlpha = applyMorphologicalClosing(currentAlpha, options.morphology.closing); + } + } + + return { + alpha: currentAlpha, + foreground: foreground !== undefined ? copyForegroundImage(foreground) : undefined, + }; +} + +function copyAlphaChannel(alpha: AlphaChannelData): AlphaChannelData { + return { + width: alpha.width, + height: alpha.height, + channels: alpha.channels, + format: alpha.format, + data: new Float32Array(alpha.data), + }; +} + +function copyForegroundImage(foreground: ForegroundImageData): ForegroundImageData { + return { + width: foreground.width, + height: foreground.height, + channels: foreground.channels, + format: foreground.format, + data: new Float32Array(foreground.data), + }; +} + +function validateAlphaChannel(alpha: AlphaChannelData): void { + if (alpha.format !== "alpha" || alpha.channels !== 1) { + throw new CleanupError( + `Alpha channel must have format "alpha" and 1 channel, got format "${alpha.format}" and ${alpha.channels} channels.`, + ); + } + + if (!Number.isInteger(alpha.width) || alpha.width <= 0) { + throw new CleanupError(`Alpha channel width must be a positive integer, got ${alpha.width}.`); + } + + if (!Number.isInteger(alpha.height) || alpha.height <= 0) { + throw new CleanupError(`Alpha channel height must be a positive integer, got ${alpha.height}.`); + } + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new CleanupError( + `Alpha channel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } + + for (let pixel = 0; pixel < expectedLength; pixel++) { + const value = alpha.data[pixel]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new CleanupError( + `Alpha value ${value} at pixel ${pixel} is outside the valid range [0, 1].`, + ); + } + } +} + +function validateForegroundImage(foreground: ForegroundImageData, alpha: AlphaChannelData): void { + if (foreground.format !== "linear-rgb" || foreground.channels !== 3) { + throw new CleanupError( + `Foreground image must have format "linear-rgb" and 3 channels, got format "${foreground.format}" and ${foreground.channels} channels.`, + ); + } + + if (foreground.width !== alpha.width || foreground.height !== alpha.height) { + throw new CleanupError( + `Foreground image dimensions must match alpha channel dimensions. ` + + `Foreground is ${foreground.width}x${foreground.height}, alpha is ${alpha.width}x${alpha.height}.`, + ); + } + + const expectedLength = foreground.width * foreground.height * 3; + if (foreground.data.length !== expectedLength) { + throw new CleanupError( + `Foreground image buffer length ${foreground.data.length} does not match expected ${expectedLength}.`, + ); + } + + for (let pixel = 0; pixel < expectedLength; pixel++) { + const value = foreground.data[pixel]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new CleanupError( + `Foreground value ${value} at index ${pixel} is outside the valid range [0, 1].`, + ); + } + } +} diff --git a/src/cleanup/index.ts b/src/cleanup/index.ts new file mode 100644 index 0000000..0ce9ec9 --- /dev/null +++ b/src/cleanup/index.ts @@ -0,0 +1,12 @@ +export { cleanup } from "./cleanup.js"; +export { CleanupError } from "./cleanup-errors.js"; +export type { + AlphaThresholdOptions, + CleanupOptions, + CleanupResult, + Connectivity, + MorphologyOptions, + NoiseRemovalOptions, + StructuringElementOptions, + StructuringElementShape, +} from "./cleanup-types.js"; diff --git a/src/cleanup/morphology.ts b/src/cleanup/morphology.ts new file mode 100644 index 0000000..89f15be --- /dev/null +++ b/src/cleanup/morphology.ts @@ -0,0 +1,175 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import { CleanupError } from "./cleanup-errors.js"; +import type { StructuringElementOptions, StructuringElementShape } from "./cleanup-types.js"; + +/** + * Apply morphological opening (erosion followed by dilation). + * + * Opening removes small protrusions from the foreground shape while preserving + * larger structures. The input alpha channel is not mutated. + * + * @param alpha - The alpha channel to open. + * @param options - Structuring element configuration. + * @returns A new alpha channel after opening. + * @throws CleanupError when the configuration is invalid. + */ +export function applyMorphologicalOpening( + alpha: AlphaChannelData, + options: StructuringElementOptions, +): AlphaChannelData { + validateStructuringElementOptions(options); + validateAlphaChannel(alpha); + + const element = buildStructuringElement(options.shape, options.radius); + const eroded = applyErosion(alpha, element); + return applyDilation(eroded, element); +} + +/** + * Apply morphological closing (dilation followed by erosion). + * + * Closing fills small holes in the foreground shape while preserving larger + * structures. The input alpha channel is not mutated. + * + * @param alpha - The alpha channel to close. + * @param options - Structuring element configuration. + * @returns A new alpha channel after closing. + * @throws CleanupError when the configuration is invalid. + */ +export function applyMorphologicalClosing( + alpha: AlphaChannelData, + options: StructuringElementOptions, +): AlphaChannelData { + validateStructuringElementOptions(options); + validateAlphaChannel(alpha); + + const element = buildStructuringElement(options.shape, options.radius); + const dilated = applyDilation(alpha, element); + return applyErosion(dilated, element); +} + +/** + * Apply grayscale erosion: each output pixel is the minimum alpha value under + * the structuring element. + */ +function applyErosion( + alpha: AlphaChannelData, + element: readonly (readonly [number, number])[], +): AlphaChannelData { + return applyMorphology(alpha, element, Number.POSITIVE_INFINITY, Math.min); +} + +/** + * Apply grayscale dilation: each output pixel is the maximum alpha value under + * the structuring element. + */ +function applyDilation( + alpha: AlphaChannelData, + element: readonly (readonly [number, number])[], +): AlphaChannelData { + return applyMorphology(alpha, element, Number.NEGATIVE_INFINITY, Math.max); +} + +function applyMorphology( + alpha: AlphaChannelData, + element: readonly (readonly [number, number])[], + identity: number, + combine: (a: number, b: number) => number, +): AlphaChannelData { + const { width, height } = alpha; + const pixelCount = width * height; + const output = new Float32Array(pixelCount); + + for (let y = 0; y < height; y++) { + for (let x = 0; x < width; x++) { + let value = identity; + for (const [dx, dy] of element) { + const nx = x + dx; + const ny = y + dy; + if (nx < 0 || nx >= width || ny < 0 || ny >= height) { + continue; + } + value = combine(value, alpha.data[ny * width + nx]); + } + output[y * width + x] = value; + } + } + + return { + width, + height, + channels: 1, + format: "alpha", + data: output, + }; +} + +function buildStructuringElement( + shape: StructuringElementShape, + radius: number, +): readonly (readonly [number, number])[] { + const offsets: [number, number][] = []; + + for (let dy = -radius; dy <= radius; dy++) { + for (let dx = -radius; dx <= radius; dx++) { + if (matchesShape(shape, dx, dy, radius)) { + offsets.push([dx, dy]); + } + } + } + + return offsets; +} + +function matchesShape( + shape: StructuringElementShape, + dx: number, + dy: number, + radius: number, +): boolean { + switch (shape) { + case "square": + return Math.abs(dx) <= radius && Math.abs(dy) <= radius; + case "disk": + return dx * dx + dy * dy <= radius * radius; + case "cross": + return dx === 0 || dy === 0; + default: + return false; + } +} + +function validateStructuringElementOptions(options: StructuringElementOptions): void { + if (!["square", "disk", "cross"].includes(options.shape)) { + throw new CleanupError(`Unsupported structuring element shape: ${options.shape}.`); + } + + if (!Number.isInteger(options.radius) || options.radius < 0) { + throw new CleanupError( + `Structuring element radius must be a non-negative integer, got ${options.radius}.`, + ); + } +} + +function validateAlphaChannel(alpha: AlphaChannelData): void { + if (alpha.format !== "alpha" || alpha.channels !== 1) { + throw new CleanupError( + `Alpha channel must have format "alpha" and 1 channel, got format "${alpha.format}" and ${alpha.channels} channels.`, + ); + } + + if (!Number.isInteger(alpha.width) || alpha.width <= 0) { + throw new CleanupError(`Alpha channel width must be a positive integer, got ${alpha.width}.`); + } + + if (!Number.isInteger(alpha.height) || alpha.height <= 0) { + throw new CleanupError(`Alpha channel height must be a positive integer, got ${alpha.height}.`); + } + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new CleanupError( + `Alpha channel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } +} diff --git a/src/cleanup/noise-removal.ts b/src/cleanup/noise-removal.ts new file mode 100644 index 0000000..b2ecd2d --- /dev/null +++ b/src/cleanup/noise-removal.ts @@ -0,0 +1,151 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import { CleanupError } from "./cleanup-errors.js"; +import type { Connectivity, NoiseRemovalOptions } from "./cleanup-types.js"; + +/** + * Remove isolated alpha artifacts using connected-component analysis. + * + * Connected components of non-transparent pixels (alpha > 0) with fewer than + * maxArtifactSize pixels are set to fully transparent. The input alpha channel + * is not mutated. + * + * The default connectivity is 4. Valid values are 4 and 8. + * + * @param alpha - The alpha channel to clean. + * @param options - Noise removal configuration. + * @returns A new alpha channel with small artifacts removed. + * @throws CleanupError when the configuration is invalid. + */ +export function applyNoiseRemoval( + alpha: AlphaChannelData, + options: NoiseRemovalOptions, +): AlphaChannelData { + const { maxArtifactSize, connectivity = 4 } = options; + + validateNoiseRemovalOptions(maxArtifactSize, connectivity); + validateAlphaChannel(alpha); + + const pixelCount = alpha.width * alpha.height; + const output = new Float32Array(alpha.data); + const visited = new Uint8Array(pixelCount); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + if (visited[pixel] || alpha.data[pixel] === 0) { + continue; + } + + const component = collectComponent(alpha, pixel, connectivity, visited); + + if (component.length < maxArtifactSize) { + for (const index of component) { + output[index] = 0; + } + } + } + + return { + width: alpha.width, + height: alpha.height, + channels: 1, + format: "alpha", + data: output, + }; +} + +function collectComponent( + alpha: AlphaChannelData, + startPixel: number, + connectivity: Connectivity, + visited: Uint8Array, +): number[] { + const component: number[] = []; + const stack: number[] = [startPixel]; + + const width = alpha.width; + const height = alpha.height; + const offsets = connectivity === 4 ? FOUR_CONNECTIVITY : EIGHT_CONNECTIVITY; + + while (stack.length > 0) { + const pixel = stack.pop(); + if (pixel === undefined) { + break; + } + + if (visited[pixel] || alpha.data[pixel] === 0) { + continue; + } + + visited[pixel] = 1; + component.push(pixel); + + const x = pixel % width; + const y = Math.floor(pixel / width); + + for (const [dx, dy] of offsets) { + const nx = x + dx; + const ny = y + dy; + + if (nx < 0 || nx >= width || ny < 0 || ny >= height) { + continue; + } + + const neighbor = ny * width + nx; + if (!visited[neighbor] && alpha.data[neighbor] > 0) { + stack.push(neighbor); + } + } + } + + return component; +} + +const FOUR_CONNECTIVITY: readonly (readonly [number, number])[] = [ + [0, -1], + [0, 1], + [-1, 0], + [1, 0], +]; + +const EIGHT_CONNECTIVITY: readonly (readonly [number, number])[] = [ + [-1, -1], + [-1, 0], + [-1, 1], + [0, -1], + [0, 1], + [1, -1], + [1, 0], + [1, 1], +]; + +function validateNoiseRemovalOptions(maxArtifactSize: number, connectivity: Connectivity): void { + if (!Number.isInteger(maxArtifactSize) || maxArtifactSize <= 0) { + throw new CleanupError(`maxArtifactSize must be a positive integer, got ${maxArtifactSize}.`); + } + + if (connectivity !== 4 && connectivity !== 8) { + throw new CleanupError(`connectivity must be 4 or 8, got ${connectivity}.`); + } +} + +function validateAlphaChannel(alpha: AlphaChannelData): void { + if (alpha.format !== "alpha" || alpha.channels !== 1) { + throw new CleanupError( + `Alpha channel must have format "alpha" and 1 channel, got format "${alpha.format}" and ${alpha.channels} channels.`, + ); + } + + if (!Number.isInteger(alpha.width) || alpha.width <= 0) { + throw new CleanupError(`Alpha channel width must be a positive integer, got ${alpha.width}.`); + } + + if (!Number.isInteger(alpha.height) || alpha.height <= 0) { + throw new CleanupError(`Alpha channel height must be a positive integer, got ${alpha.height}.`); + } + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new CleanupError( + `Alpha channel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } +} diff --git a/src/color/color-converter.ts b/src/color/color-converter.ts new file mode 100644 index 0000000..00849ba --- /dev/null +++ b/src/color/color-converter.ts @@ -0,0 +1,112 @@ +import type { ImageData } from "../io/index.js"; +import type { LinearImageData } from "./color-types.js"; +import { ColorConversionError } from "./color-errors.js"; +import { linearToSrgbChannel, srgbToLinearChannel } from "./srgb.js"; + +const EXPECTED_CHANNELS = 4; + +function validateImageData(image: ImageData): void { + if (image.format !== "rgba8") { + throw new ColorConversionError(`Expected rgba8 format, got ${image.format}`); + } + + if (image.channels !== EXPECTED_CHANNELS) { + throw new ColorConversionError(`Expected ${EXPECTED_CHANNELS} channels, got ${image.channels}`); + } + + const expectedLength = image.width * image.height * EXPECTED_CHANNELS; + if (image.data.length !== expectedLength) { + throw new ColorConversionError( + `Pixel buffer length ${image.data.length} does not match expected length ${expectedLength}`, + ); + } +} + +function validateLinearImageData(linear: LinearImageData): void { + if (linear.format !== "linear-rgba8") { + throw new ColorConversionError(`Expected linear-rgba8 format, got ${linear.format}`); + } + + if (linear.channels !== EXPECTED_CHANNELS) { + throw new ColorConversionError( + `Expected ${EXPECTED_CHANNELS} channels, got ${linear.channels}`, + ); + } + + const expectedLength = linear.width * linear.height * EXPECTED_CHANNELS; + if (linear.data.length !== expectedLength) { + throw new ColorConversionError( + `Linear pixel buffer length ${linear.data.length} does not match expected length ${expectedLength}`, + ); + } +} + +function toUint8(value: number): number { + return Math.max(0, Math.min(255, Math.round(value * 255))); +} + +/** + * Convert an RGBA8 image to linear RGB color space. + * + * The alpha channel is preserved as a normalized value in the range [0, 1]. + * The returned image is immutable; the input image is not modified. + * + * @param image - The RGBA8 image to convert. + * @returns A new image in linear RGB color space. + * @throws ColorConversionError when the input format is not supported. + */ +export function srgbToLinear(image: ImageData): LinearImageData { + validateImageData(image); + + const pixelCount = image.width * image.height; + const data = new Float32Array(pixelCount * EXPECTED_CHANNELS); + + for (let i = 0; i < image.data.length; i += EXPECTED_CHANNELS) { + data[i] = srgbToLinearChannel(image.data[i] / 255); + data[i + 1] = srgbToLinearChannel(image.data[i + 1] / 255); + data[i + 2] = srgbToLinearChannel(image.data[i + 2] / 255); + data[i + 3] = image.data[i + 3] / 255; + } + + return { + width: image.width, + height: image.height, + channels: EXPECTED_CHANNELS, + format: "linear-rgba8", + data, + path: image.path, + }; +} + +/** + * Convert a linear RGB image to sRGB color space. + * + * The alpha channel is restored to the range [0, 255]. The returned image is + * immutable; the input image is not modified. + * + * @param linear - The linear RGB image to convert. + * @returns A new RGBA8 image in sRGB color space. + * @throws ColorConversionError when the input format is not supported. + */ +export function linearToSrgb(linear: LinearImageData): ImageData { + validateLinearImageData(linear); + + const pixelCount = linear.width * linear.height; + const data = new Uint8Array(pixelCount * EXPECTED_CHANNELS); + + for (let i = 0; i < linear.data.length; i += EXPECTED_CHANNELS) { + data[i] = toUint8(linearToSrgbChannel(linear.data[i])); + data[i + 1] = toUint8(linearToSrgbChannel(linear.data[i + 1])); + data[i + 2] = toUint8(linearToSrgbChannel(linear.data[i + 2])); + data[i + 3] = toUint8(linear.data[i + 3]); + } + + return { + width: linear.width, + height: linear.height, + channels: EXPECTED_CHANNELS, + format: "rgba8", + data, + path: linear.path, + }; +} diff --git a/src/color/color-errors.ts b/src/color/color-errors.ts new file mode 100644 index 0000000..52e1caf --- /dev/null +++ b/src/color/color-errors.ts @@ -0,0 +1,19 @@ +/** + * Error thrown when a color conversion cannot be performed. + * + * This error is internal to the color module and is not part of the public API. + */ +export class ColorConversionError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + } = {}, + ) { + super(message); + this.name = "ColorConversionError"; + this.cause = options.cause; + } + + readonly cause?: unknown; +} diff --git a/src/color/color-types.ts b/src/color/color-types.ts new file mode 100644 index 0000000..3918450 --- /dev/null +++ b/src/color/color-types.ts @@ -0,0 +1,15 @@ +/** + * Immutable decoded image data in linear RGB color space. + * + * The buffer is owned by the caller after conversion. AlphaForge processing + * assumes `linear-rgba8` data with four floats per pixel in RGBA order. All + * channel values are normalized to the range [0, 1]. + */ +export interface LinearImageData { + readonly width: number; + readonly height: number; + readonly channels: number; + readonly format: "linear-rgba8"; + readonly data: Float32Array; + readonly path: string; +} diff --git a/src/color/index.ts b/src/color/index.ts new file mode 100644 index 0000000..fb2231f --- /dev/null +++ b/src/color/index.ts @@ -0,0 +1,2 @@ +export { srgbToLinear, linearToSrgb } from "./color-converter.js"; +export type { LinearImageData } from "./color-types.js"; diff --git a/src/color/srgb.ts b/src/color/srgb.ts new file mode 100644 index 0000000..b4fe3b6 --- /dev/null +++ b/src/color/srgb.ts @@ -0,0 +1,31 @@ +/** + * Convert a single normalized sRGB channel value to linear RGB. + * + * Implements the IEC 61966-2-1 sRGB transfer function. + * + * @param value - Normalized sRGB channel value in the range [0, 1]. + * @returns Normalized linear RGB channel value in the range [0, 1]. + */ +export function srgbToLinearChannel(value: number): number { + if (value <= 0.04045) { + return value / 12.92; + } + + return ((value + 0.055) / 1.055) ** 2.4; +} + +/** + * Convert a single normalized linear RGB channel value to sRGB. + * + * Implements the inverse IEC 61966-2-1 sRGB transfer function. + * + * @param value - Normalized linear RGB channel value in the range [0, 1]. + * @returns Normalized sRGB channel value in the range [0, 1]. + */ +export function linearToSrgbChannel(value: number): number { + if (value <= 0.0031308) { + return value * 12.92; + } + + return 1.055 * value ** (1 / 2.4) - 0.055; +} diff --git a/src/export/export-errors.ts b/src/export/export-errors.ts new file mode 100644 index 0000000..d97dfb3 --- /dev/null +++ b/src/export/export-errors.ts @@ -0,0 +1,23 @@ +/** + * Error thrown when a PNG export cannot be completed. + * + * This includes invalid inputs, incompatible dimensions, unsupported formats, + * and encoder failures. + */ +export class ExportError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly context?: { readonly path: string }; + } = {}, + ) { + super(message); + this.name = "ExportError"; + this.cause = options.cause; + this.context = options.context; + } + + readonly cause?: unknown; + readonly context?: { readonly path: string }; +} diff --git a/src/export/export-types.ts b/src/export/export-types.ts new file mode 100644 index 0000000..5ad9445 --- /dev/null +++ b/src/export/export-types.ts @@ -0,0 +1,37 @@ +import type { AlphaChannelData } from "../reconstruction/index.js"; +import type { ForegroundImageData } from "../reconstruction/index.js"; + +/** + * Options for {@link exportPng}. + */ +export interface ExportPngOptions { + /** + * Reconstructed foreground image in linear RGB color space. + */ + readonly foreground: ForegroundImageData; + + /** + * Reconstructed alpha channel in the range [0, 1]. + */ + readonly alpha: AlphaChannelData; + + /** + * Destination file path for the PNG output. + */ + readonly path: string; +} + +/** + * Result of a successful PNG export. + */ +export interface ExportResult { + /** + * Path to the written PNG file. + */ + readonly path: string; + + /** + * Size of the written PNG file in bytes. + */ + readonly bytes: number; +} diff --git a/src/export/index.ts b/src/export/index.ts new file mode 100644 index 0000000..dfb151f --- /dev/null +++ b/src/export/index.ts @@ -0,0 +1,3 @@ +export { exportPng } from "./png-exporter.js"; +export { ExportError } from "./export-errors.js"; +export type { ExportPngOptions, ExportResult } from "./export-types.js"; diff --git a/src/export/png-exporter.ts b/src/export/png-exporter.ts new file mode 100644 index 0000000..e4a91dd --- /dev/null +++ b/src/export/png-exporter.ts @@ -0,0 +1,154 @@ +import sharp from "sharp"; +import type { LinearImageData } from "../color/index.js"; +import { linearToSrgb } from "../color/index.js"; +import type { AlphaChannelData } from "../reconstruction/index.js"; +import type { ForegroundImageData } from "../reconstruction/index.js"; +import { ExportError } from "./export-errors.js"; +import type { ExportPngOptions, ExportResult } from "./export-types.js"; + +/** + * Export reconstructed foreground and alpha data to a PNG file. + * + * The foreground and alpha are combined into a temporary linear RGBA image, + * converted to sRGB using the existing `linearToSrgb` color conversion, and + * encoded as a straight-alpha RGBA8 PNG. + * + * @param options - Foreground image, alpha channel, and destination path. + * @returns Path and size of the written PNG file. + * @throws ExportError when inputs are invalid or the PNG cannot be written. + */ +export async function exportPng(options: ExportPngOptions): Promise { + const { foreground, alpha, path } = options; + + validatePath(path); + validateForegroundImageData(foreground); + validateAlphaChannelData(alpha); + validateDimensions(foreground, alpha); + + const pixelCount = foreground.width * foreground.height; + const linearRgba = new Float32Array(pixelCount * 4); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const foregroundIndex = pixel * 3; + const rgbaIndex = pixel * 4; + + linearRgba[rgbaIndex] = foreground.data[foregroundIndex]; + linearRgba[rgbaIndex + 1] = foreground.data[foregroundIndex + 1]; + linearRgba[rgbaIndex + 2] = foreground.data[foregroundIndex + 2]; + linearRgba[rgbaIndex + 3] = alpha.data[pixel]; + } + + const linearImageData: LinearImageData = { + width: foreground.width, + height: foreground.height, + channels: 4, + format: "linear-rgba8", + data: linearRgba, + path, + }; + + const imageData = linearToSrgb(linearImageData); + + try { + const info = await sharp(imageData.data, { + raw: { + width: imageData.width, + height: imageData.height, + channels: 4, + }, + }) + .png() + .toFile(path); + + return { + path, + bytes: info.size, + }; + } catch (error) { + throw new ExportError(`Failed to write PNG file to ${path}`, { + cause: error, + context: { path }, + }); + } +} + +function validatePath(path: string): void { + if (typeof path !== "string" || path.length === 0) { + throw new ExportError("Export path must be a non-empty string"); + } +} + +function validateForegroundImageData(foreground: ForegroundImageData): void { + if (foreground.format !== "linear-rgb" || foreground.channels !== 3) { + throw new ExportError( + `Foreground image must have format "linear-rgb" and 3 channels, got format "${foreground.format}" and ${foreground.channels} channels.`, + ); + } + + validateDimensionsPositive(foreground); + + const expectedLength = foreground.width * foreground.height * 3; + if (foreground.data.length !== expectedLength) { + throw new ExportError( + `Foreground image buffer length ${foreground.data.length} does not match expected ${expectedLength}.`, + ); + } + + for (let index = 0; index < foreground.data.length; index++) { + const value = foreground.data[index]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ExportError( + `Foreground value ${value} at index ${index} is outside the valid range [0, 1].`, + ); + } + } +} + +function validateAlphaChannelData(alpha: AlphaChannelData): void { + if (alpha.format !== "alpha" || alpha.channels !== 1) { + throw new ExportError( + `Alpha channel must have format "alpha" and 1 channel, got format "${alpha.format}" and ${alpha.channels} channels.`, + ); + } + + validateDimensionsPositive(alpha); + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new ExportError( + `Alpha channel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } + + for (let pixel = 0; pixel < alpha.data.length; pixel++) { + const value = alpha.data[pixel]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ExportError( + `Alpha value ${value} at pixel ${pixel} is outside the valid range [0, 1].`, + ); + } + } +} + +function validateDimensionsPositive(image: { + readonly width: number; + readonly height: number; +}): void { + if (!Number.isInteger(image.width) || image.width <= 0) { + throw new ExportError(`Image width must be a positive integer, got ${image.width}.`); + } + + if (!Number.isInteger(image.height) || image.height <= 0) { + throw new ExportError(`Image height must be a positive integer, got ${image.height}.`); + } +} + +function validateDimensions(foreground: ForegroundImageData, alpha: AlphaChannelData): void { + if (foreground.width !== alpha.width || foreground.height !== alpha.height) { + throw new ExportError( + `Foreground and alpha dimensions must match. ` + + `Foreground is ${foreground.width}x${foreground.height}, ` + + `alpha is ${alpha.width}x${alpha.height}.`, + ); + } +} diff --git a/src/index.ts b/src/index.ts new file mode 100644 index 0000000..02a06f3 --- /dev/null +++ b/src/index.ts @@ -0,0 +1,70 @@ +export const VERSION = "0.9.0"; + +export { loadImage } from "./io/index.js"; +export type { ImageData, LoadImageOptions } from "./io/index.js"; +export { ImageLoadError } from "./io/index.js"; + +export { srgbToLinear, linearToSrgb } from "./color/index.js"; +export type { LinearImageData } from "./color/index.js"; + +export { reconstructPipeline } from "./pipeline/index.js"; +export { PipelineError } from "./pipeline/index.js"; +export type { + ReconstructPipelineOptions, + ReconstructPipelineResult, + ReconstructPipelineCleanupOptions, +} from "./pipeline/index.js"; + +export { + validateImages, + assertImagesValid, + validateImageMetadata, + validateImageDimensions, + ValidationError, + MetadataValidationError, + DimensionValidationError, + measureBackgroundColor, + validateBackgroundColors, + assertBackgroundColorsValid, + BackgroundMismatchError, + DEFAULT_BACKGROUND_BORDER_WIDTH, + DEFAULT_BACKGROUND_MISMATCH_THRESHOLD, +} from "./validation/index.js"; + +export type { + ValidationIssue, + ValidationResult, + MeasuredBackgroundColor, + BackgroundValidationOptions, + BackgroundValidationResult, + BackgroundMismatchContext, +} from "./validation/index.js"; + +export { reconstructAlpha } from "./reconstruction/index.js"; +export { AlphaReconstructionError } from "./reconstruction/index.js"; +export type { + ReconstructAlphaOptions, + AlphaChannelData, + ReconstructionInput, +} from "./reconstruction/index.js"; + +export { reconstructForeground } from "./reconstruction/index.js"; +export { ForegroundReconstructionError } from "./reconstruction/index.js"; +export type { ReconstructForegroundOptions, ForegroundImageData } from "./reconstruction/index.js"; + +export { cleanup } from "./cleanup/index.js"; +export { CleanupError } from "./cleanup/index.js"; +export type { + CleanupOptions, + CleanupResult, + AlphaThresholdOptions, + NoiseRemovalOptions, + MorphologyOptions, + StructuringElementOptions, + StructuringElementShape, + Connectivity, +} from "./cleanup/index.js"; + +export { exportPng } from "./export/index.js"; +export { ExportError } from "./export/index.js"; +export type { ExportPngOptions, ExportResult } from "./export/index.js"; diff --git a/src/io/image-loader.ts b/src/io/image-loader.ts new file mode 100644 index 0000000..9bffa04 --- /dev/null +++ b/src/io/image-loader.ts @@ -0,0 +1,17 @@ +import type { ImageData, LoadImageOptions } from "./image-types.js"; +import { loadImageWithSharp } from "./sharp-loader.js"; + +export type { ImageData, LoadImageOptions } from "./image-types.js"; +export { ImageLoadError } from "./image-types.js"; + +/** + * Load and normalize an image file to the AlphaForge RGBA8 representation. + * + * @param path - File system path to the image. + * @param options - Optional loading configuration. + * @returns Immutable RGBA8 image data. + * @throws ImageLoadError when loading or decoding fails. + */ +export async function loadImage(path: string, options: LoadImageOptions = {}): Promise { + return loadImageWithSharp(path, options); +} diff --git a/src/io/image-types.ts b/src/io/image-types.ts new file mode 100644 index 0000000..1875eed --- /dev/null +++ b/src/io/image-types.ts @@ -0,0 +1,55 @@ +/** + * Normalized pixel format used by all AlphaForge processing. + * + * Currently only RGBA 8-bit per channel is supported. + */ +export type PixelFormat = "rgba8"; + +/** + * Immutable decoded image data. + * + * The buffer is owned by the caller after loading. AlphaForge processing + * assumes `rgba8` data with four bytes per pixel in RGBA order. + */ +export interface ImageData { + readonly width: number; + readonly height: number; + readonly channels: number; + readonly format: PixelFormat; + readonly data: Uint8Array; + readonly path: string; +} + +/** + * Options for loading an image. + * + * Intentionally minimal in this milestone; future versions may allow + * Buffer / Uint8Array sources or forced pixel formats. + */ +export interface LoadImageOptions { + readonly format?: PixelFormat; +} + +/** + * Error thrown when an image cannot be loaded or decoded. + * + * The original error is preserved in `cause` and the requested path is + * included in `context` so callers can produce actionable diagnostics. + */ +export class ImageLoadError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly context?: { readonly path: string }; + } = {}, + ) { + super(message); + this.name = "ImageLoadError"; + this.cause = options.cause; + this.context = options.context; + } + + readonly cause?: unknown; + readonly context?: { readonly path: string }; +} diff --git a/src/io/index.ts b/src/io/index.ts new file mode 100644 index 0000000..5d519da --- /dev/null +++ b/src/io/index.ts @@ -0,0 +1,3 @@ +export { loadImage } from "./image-loader.js"; +export type { ImageData, LoadImageOptions } from "./image-types.js"; +export { ImageLoadError } from "./image-types.js"; diff --git a/src/io/sharp-loader.ts b/src/io/sharp-loader.ts new file mode 100644 index 0000000..25836d5 --- /dev/null +++ b/src/io/sharp-loader.ts @@ -0,0 +1,45 @@ +import sharp from "sharp"; +import type { ImageData, LoadImageOptions } from "./image-types.js"; +import { ImageLoadError } from "./image-types.js"; + +/** + * Decode an image file using sharp and normalize it to RGBA8. + * + * @param path - File system path to the image. + * @param options - Loading options. + * @returns Normalized RGBA8 image data. + * @throws ImageLoadError when the file cannot be read or decoded. + */ +export async function loadImageWithSharp( + path: string, + options: LoadImageOptions = {}, +): Promise { + const requestedFormat = options.format ?? "rgba8"; + + if (requestedFormat !== "rgba8") { + throw new ImageLoadError(`Unsupported pixel format: ${requestedFormat}`, { + context: { path }, + }); + } + + try { + const { data, info } = await sharp(path) + .ensureAlpha() + .raw() + .toBuffer({ resolveWithObject: true }); + + return { + width: info.width, + height: info.height, + channels: info.channels, + format: "rgba8", + data: new Uint8Array(data.buffer, data.byteOffset, data.byteLength), + path, + }; + } catch (error) { + throw new ImageLoadError(`Failed to load image from ${path}`, { + cause: error, + context: { path }, + }); + } +} diff --git a/src/pipeline/index.ts b/src/pipeline/index.ts new file mode 100644 index 0000000..4216f34 --- /dev/null +++ b/src/pipeline/index.ts @@ -0,0 +1,7 @@ +export { reconstructPipeline } from "./reconstruct-pipeline.js"; +export { PipelineError } from "./pipeline-errors.js"; +export type { + ReconstructPipelineOptions, + ReconstructPipelineResult, + ReconstructPipelineCleanupOptions, +} from "./pipeline-types.js"; diff --git a/src/pipeline/pipeline-errors.ts b/src/pipeline/pipeline-errors.ts new file mode 100644 index 0000000..d88ef48 --- /dev/null +++ b/src/pipeline/pipeline-errors.ts @@ -0,0 +1,20 @@ +/** + * Error thrown when the reconstruction pipeline fails. + * + * The original error from the underlying stage is preserved in `cause` so + * callers can produce actionable diagnostics. + */ +export class PipelineError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + } = {}, + ) { + super(message); + this.name = "PipelineError"; + this.cause = options.cause; + } + + readonly cause?: unknown; +} diff --git a/src/pipeline/pipeline-types.ts b/src/pipeline/pipeline-types.ts new file mode 100644 index 0000000..80911a3 --- /dev/null +++ b/src/pipeline/pipeline-types.ts @@ -0,0 +1,103 @@ +import type { AlphaChannelData, ForegroundImageData } from "../reconstruction/index.js"; +import type { + AlphaThresholdOptions, + MorphologyOptions, + NoiseRemovalOptions, +} from "../cleanup/index.js"; + +/** + * Configuration for optional cleanup stages inside the reconstruction pipeline. + * + * The pipeline supplies the alpha channel and foreground image after + * reconstruction, so callers only configure the stage-specific options. + */ +export interface ReconstructPipelineCleanupOptions { + /** + * Optional alpha thresholding stage. + */ + readonly threshold?: AlphaThresholdOptions; + + /** + * Optional noise removal stage. + */ + readonly noiseRemoval?: NoiseRemovalOptions; + + /** + * Optional morphological cleanup stage. + */ + readonly morphology?: MorphologyOptions; +} + +/** + * Options for the full reconstruction pipeline. + */ +export interface ReconstructPipelineOptions { + /** + * Path to the first observation image. + */ + readonly observationAPath: string; + + /** + * Path to the second observation image. + */ + readonly observationBPath: string; + + /** + * Known linear RGB background color used for observation A. + */ + readonly backgroundA: readonly [number, number, number]; + + /** + * Known linear RGB background color used for observation B. + */ + readonly backgroundB: readonly [number, number, number]; + + /** + * Optional deterministic cleanup stages. + */ + readonly cleanup?: ReconstructPipelineCleanupOptions; + + /** + * Optional background mismatch validation. + * + * When provided, the pipeline compares the declared background colors against + * the colors measured from the image borders in linear RGB space. If either + * observation exceeds the configured threshold, the pipeline throws a + * {@link BackgroundMismatchError} instead of continuing with invalid + * mathematical assumptions. + * + * The default threshold is an engineering default that may be refined by + * future benchmarking. + */ + readonly backgroundValidation?: { + /** + * Border width in pixels to sample for each observation. + * + * Defaults to the library-wide default border width. + */ + readonly borderWidth?: number; + + /** + * Maximum acceptable linear RGB distance between declared and measured + * background colors. + * + * Defaults to the library-wide default mismatch threshold. + */ + readonly threshold?: number; + }; +} + +/** + * Result of the full reconstruction pipeline. + */ +export interface ReconstructPipelineResult { + /** + * Reconstructed scalar alpha channel. + */ + readonly alpha: AlphaChannelData; + + /** + * Reconstructed linear RGB foreground image. + */ + readonly foreground: ForegroundImageData; +} diff --git a/src/pipeline/reconstruct-pipeline.ts b/src/pipeline/reconstruct-pipeline.ts new file mode 100644 index 0000000..c34cf8e --- /dev/null +++ b/src/pipeline/reconstruct-pipeline.ts @@ -0,0 +1,90 @@ +import { loadImage } from "../io/index.js"; +import { assertImagesValid, assertBackgroundColorsValid } from "../validation/index.js"; +import { srgbToLinear } from "../color/index.js"; +import { reconstructAlpha, reconstructForeground } from "../reconstruction/index.js"; +import { cleanup } from "../cleanup/index.js"; +import { PipelineError } from "./pipeline-errors.js"; +import type { ReconstructPipelineOptions, ReconstructPipelineResult } from "./pipeline-types.js"; + +/** + * Run the complete reconstruction pipeline. + * + * Orchestrates the existing modules in order: + * + * loadImage(A) + loadImage(B) + * → assertImagesValid + * → srgbToLinear + * → reconstructAlpha + * → reconstructForeground + * → cleanup (optional) + * → { alpha, foreground } + * + * Both observations and their backgrounds are passed equally to the foreground + * reconstruction stage. Swapping the order of the observations (and their + * corresponding backgrounds) does not change the output. + * + * The pipeline does not implement any algorithm itself. It only coordinates + * execution and propagates configuration. + * + * @param options - Input paths, background colors, and optional cleanup config. + * @returns The reconstructed alpha channel and foreground image. + * @throws PipelineError when any stage fails. + */ +export async function reconstructPipeline( + options: ReconstructPipelineOptions, +): Promise { + try { + const [imageA, imageB] = await Promise.all([ + loadImage(options.observationAPath), + loadImage(options.observationBPath), + ]); + + assertImagesValid(imageA, imageB); + + if (options.backgroundValidation !== undefined) { + assertBackgroundColorsValid({ + imageA, + imageB, + backgroundA: options.backgroundA, + backgroundB: options.backgroundB, + borderWidth: options.backgroundValidation.borderWidth, + threshold: options.backgroundValidation.threshold, + }); + } + + const linearA = srgbToLinear(imageA); + const linearB = srgbToLinear(imageB); + + const alpha = reconstructAlpha({ + input1: { observation: linearA, background: options.backgroundA }, + input2: { observation: linearB, background: options.backgroundB }, + }); + + const foreground = reconstructForeground({ + inputs: [ + { observation: linearA, background: options.backgroundA }, + { observation: linearB, background: options.backgroundB }, + ], + alpha, + }); + + if (options.cleanup === undefined) { + return { alpha, foreground }; + } + + const cleanupResult = cleanup({ + alpha, + foreground, + threshold: options.cleanup.threshold, + noiseRemoval: options.cleanup.noiseRemoval, + morphology: options.cleanup.morphology, + }); + + return { + alpha: cleanupResult.alpha, + foreground: cleanupResult.foreground ?? foreground, + }; + } catch (error) { + throw new PipelineError("Reconstruction pipeline failed", { cause: error }); + } +} diff --git a/src/reconstruction/alpha-errors.ts b/src/reconstruction/alpha-errors.ts new file mode 100644 index 0000000..4a1bae3 --- /dev/null +++ b/src/reconstruction/alpha-errors.ts @@ -0,0 +1,20 @@ +/** + * Error thrown when alpha reconstruction cannot be performed. + * + * This includes invalid inputs, incompatible observations, and numerically + * unsafe background pairs. + */ +export class AlphaReconstructionError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + } = {}, + ) { + super(message); + this.name = "AlphaReconstructionError"; + this.cause = options.cause; + } + + readonly cause?: unknown; +} diff --git a/src/reconstruction/alpha-reconstruction.ts b/src/reconstruction/alpha-reconstruction.ts new file mode 100644 index 0000000..847b20e --- /dev/null +++ b/src/reconstruction/alpha-reconstruction.ts @@ -0,0 +1,190 @@ +import type { LinearImageData } from "../color/index.js"; +import type { AlphaChannelData, ReconstructAlphaOptions } from "./alpha-types.js"; +import { AlphaReconstructionError } from "./alpha-errors.js"; + +const EXPECTED_FORMAT = "linear-rgba8"; +const EXPECTED_CHANNELS = 4; +const EPSILON = 1e-6; + +/** + * Reconstruct a scalar alpha channel from two linear RGB observations of the + * same foreground against different known backgrounds. + * + * Implements the Porter-Duff two-observation model described in the alpha + * reconstruction specification. + * + * For each color channel, the per-channel alpha estimate is: + * + * α_c = 1 - (C1_c - C2_c) / (B1_c - B2_c) + * + * Channels where the background denominator is at or below EPSILON are + * excluded from the aggregation. The valid per-channel estimates are combined + * using the current default aggregation strategy: the unweighted mean. + * + * The final alpha value is strictly clamped to the physical range [0, 1]. + * + * @param options - The two observations and their backgrounds. + * @returns A single-channel alpha image. + * @throws AlphaReconstructionError when the inputs are invalid or numerically + * unsafe. + */ +export function reconstructAlpha(options: ReconstructAlphaOptions): AlphaChannelData { + const { input1, input2 } = options; + + validateObservation(input1.observation, "input1.observation"); + validateObservation(input2.observation, "input2.observation"); + + if ( + input1.observation.width !== input2.observation.width || + input1.observation.height !== input2.observation.height + ) { + throw new AlphaReconstructionError( + `Observations must have identical dimensions. ` + + `input1 is ${input1.observation.width}x${input1.observation.height}, ` + + `input2 is ${input2.observation.width}x${input2.observation.height}.`, + ); + } + + validateBackground(input1.background, "input1.background"); + validateBackground(input2.background, "input2.background"); + + const redDenominator = input1.background[0] - input2.background[0]; + const greenDenominator = input1.background[1] - input2.background[1]; + const blueDenominator = input1.background[2] - input2.background[2]; + + const redUsable = Math.abs(redDenominator) > EPSILON; + const greenUsable = Math.abs(greenDenominator) > EPSILON; + const blueUsable = Math.abs(blueDenominator) > EPSILON; + + if (!redUsable && !greenUsable && !blueUsable) { + throw new AlphaReconstructionError( + `Background colors are too similar: all channel differences are <= ${EPSILON}.`, + ); + } + + const pixelCount = input1.observation.width * input1.observation.height; + const output = new Float32Array(pixelCount); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const index1 = pixel * EXPECTED_CHANNELS; + const index2 = pixel * EXPECTED_CHANNELS; + + const red1 = input1.observation.data[index1]; + const green1 = input1.observation.data[index1 + 1]; + const blue1 = input1.observation.data[index1 + 2]; + const alpha1 = input1.observation.data[index1 + 3]; + + const red2 = input2.observation.data[index2]; + const green2 = input2.observation.data[index2 + 1]; + const blue2 = input2.observation.data[index2 + 2]; + const alpha2 = input2.observation.data[index2 + 3]; + + validateChannel(red1, "input1.observation red", pixel); + validateChannel(green1, "input1.observation green", pixel); + validateChannel(blue1, "input1.observation blue", pixel); + validateChannel(alpha1, "input1.observation alpha", pixel); + + validateChannel(red2, "input2.observation red", pixel); + validateChannel(green2, "input2.observation green", pixel); + validateChannel(blue2, "input2.observation blue", pixel); + validateChannel(alpha2, "input2.observation alpha", pixel); + + const estimates: number[] = []; + + if (redUsable) { + // α_r = 1 - (C1_r - C2_r) / (B1_r - B2_r) + estimates.push(1 - (red1 - red2) / redDenominator); + } + + if (greenUsable) { + // α_g = 1 - (C1_g - C2_g) / (B1_g - B2_g) + estimates.push(1 - (green1 - green2) / greenDenominator); + } + + if (blueUsable) { + // α_b = 1 - (C1_b - C2_b) / (B1_b - B2_b) + estimates.push(1 - (blue1 - blue2) / blueDenominator); + } + + // The background validation above guarantees at least one usable channel. + const aggregatedAlpha = aggregateAlphaEstimates(estimates); + + output[pixel] = Math.max(0, Math.min(1, aggregatedAlpha)); + } + + return { + width: input1.observation.width, + height: input1.observation.height, + channels: 1, + format: "alpha", + data: output, + }; +} + +/** + * Default alpha aggregation strategy: unweighted mean of valid channel estimates. + * + * This is the initial reference implementation for v0.1.0. It is deterministic + * and will remain an internal implementation detail so it can be benchmarked + * against alternative deterministic estimators (median, weighted mean, robust + * estimators, etc.) without changing the public API. + */ +function aggregateAlphaEstimates(estimates: readonly number[]): number { + let sum = 0; + for (const estimate of estimates) { + sum += estimate; + } + return sum / estimates.length; +} + +function validateObservation(observation: LinearImageData, label: string): void { + if (observation.format !== EXPECTED_FORMAT) { + throw new AlphaReconstructionError( + `${label} must have format ${EXPECTED_FORMAT}, got ${observation.format}.`, + ); + } + + if (observation.channels !== EXPECTED_CHANNELS) { + throw new AlphaReconstructionError( + `${label} must have ${EXPECTED_CHANNELS} channels, got ${observation.channels}.`, + ); + } + + if (!Number.isInteger(observation.width) || observation.width <= 0) { + throw new AlphaReconstructionError( + `${label} must have a positive integer width, got ${observation.width}.`, + ); + } + + if (!Number.isInteger(observation.height) || observation.height <= 0) { + throw new AlphaReconstructionError( + `${label} must have a positive integer height, got ${observation.height}.`, + ); + } + + const expectedLength = observation.width * observation.height * EXPECTED_CHANNELS; + if (observation.data.length !== expectedLength) { + throw new AlphaReconstructionError( + `${label} pixel buffer length ${observation.data.length} does not match expected ${expectedLength}.`, + ); + } +} + +function validateBackground(background: readonly [number, number, number], label: string): void { + for (let channel = 0; channel < 3; channel++) { + const value = background[channel]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AlphaReconstructionError( + `${label} color value at index ${channel} must be in [0, 1], got ${value}.`, + ); + } + } +} + +function validateChannel(value: number, label: string, pixel: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new AlphaReconstructionError( + `${label} value ${value} at pixel ${pixel} is outside the valid range [0, 1].`, + ); + } +} diff --git a/src/reconstruction/alpha-types.ts b/src/reconstruction/alpha-types.ts new file mode 100644 index 0000000..826e338 --- /dev/null +++ b/src/reconstruction/alpha-types.ts @@ -0,0 +1,23 @@ +import type { ReconstructionInput } from "./reconstruction-types.js"; + +/** + * Options for {@link reconstructAlpha}. + */ +export interface ReconstructAlphaOptions { + readonly input1: ReconstructionInput; + readonly input2: ReconstructionInput; +} + +/** + * A single-channel alpha image produced by alpha reconstruction. + * + * Each pixel stores a scalar alpha value in the range [0, 1]. The alpha is + * linear and is not associated with a specific file path. + */ +export interface AlphaChannelData { + readonly width: number; + readonly height: number; + readonly channels: 1; + readonly format: "alpha"; + readonly data: Float32Array; +} diff --git a/src/reconstruction/foreground-errors.ts b/src/reconstruction/foreground-errors.ts new file mode 100644 index 0000000..2cff65c --- /dev/null +++ b/src/reconstruction/foreground-errors.ts @@ -0,0 +1,21 @@ +/** + * Error thrown when foreground reconstruction cannot be performed. + * + * This includes invalid inputs, incompatible dimensions, and numerically + * unsafe values that would prevent the inverse Porter–Duff equation from + * being evaluated deterministically. + */ +export class ForegroundReconstructionError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + } = {}, + ) { + super(message); + this.name = "ForegroundReconstructionError"; + this.cause = options.cause; + } + + readonly cause?: unknown; +} diff --git a/src/reconstruction/foreground-reconstruction.ts b/src/reconstruction/foreground-reconstruction.ts new file mode 100644 index 0000000..4ca9a18 --- /dev/null +++ b/src/reconstruction/foreground-reconstruction.ts @@ -0,0 +1,223 @@ +import type { LinearImageData } from "../color/index.js"; +import type { AlphaChannelData } from "./alpha-types.js"; +import type { ForegroundImageData, ReconstructForegroundOptions } from "./foreground-types.js"; +import type { ReconstructionInput } from "./reconstruction-types.js"; +import { ForegroundReconstructionError } from "./foreground-errors.js"; + +const EXPECTED_OBSERVATION_FORMAT = "linear-rgba8"; +const EXPECTED_OBSERVATION_CHANNELS = 4; +const EXPECTED_ALPHA_FORMAT = "alpha"; +const EXPECTED_ALPHA_CHANNELS = 1; +const ALPHA_THRESHOLD = 0.01; + +/** + * Reconstruct the original foreground color from two linear RGB observations, + * their known background colors, and a previously reconstructed alpha channel. + * + * AlphaForge requires two observations of the same foreground against different + * known backgrounds. Both observations participate equally in foreground + * recovery. For each pixel, a foreground estimate is recovered from each + * observation using the inverse of the Porter–Duff "over" operator: + * + * F = (C - (1 - α)B) / α + * + * The two estimates are then averaged to produce a single symmetric result. + * + * Pixels where the alpha value is below ALPHA_THRESHOLD default to the neutral + * foreground color (0, 0, 0) rather than applying the division. All recovered + * foreground color channels are strictly clamped to the physical range [0, 1]. + * + * @param options - The two observations, their backgrounds, and reconstructed alpha. + * @returns A three-channel linear RGB foreground image. + * @throws ForegroundReconstructionError when the inputs are invalid or + * dimensions do not match. + */ +export function reconstructForeground(options: ReconstructForegroundOptions): ForegroundImageData { + const { inputs, alpha } = options; + const [input1, input2] = inputs; + + validateInput(input1, "inputs[0]"); + validateInput(input2, "inputs[1]"); + validateAlpha(alpha); + + if ( + input1.observation.width !== alpha.width || + input1.observation.height !== alpha.height || + input2.observation.width !== alpha.width || + input2.observation.height !== alpha.height + ) { + throw new ForegroundReconstructionError( + `Observations and alpha dimensions must match. ` + + `Observation 1 is ${input1.observation.width}x${input1.observation.height}, ` + + `observation 2 is ${input2.observation.width}x${input2.observation.height}, ` + + `alpha is ${alpha.width}x${alpha.height}.`, + ); + } + + const pixelCount = alpha.width * alpha.height; + const output = new Float32Array(pixelCount * 3); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const observationIndex = pixel * EXPECTED_OBSERVATION_CHANNELS; + const alphaValue = alpha.data[pixel]; + const outputIndex = pixel * 3; + + validateAlphaChannel(alphaValue, pixel); + + if (alphaValue < ALPHA_THRESHOLD) { + output[outputIndex] = 0; + output[outputIndex + 1] = 0; + output[outputIndex + 2] = 0; + continue; + } + + const oneMinusAlpha = 1 - alphaValue; + + const recovered1 = recoverForegroundFromObservation( + input1.observation, + input1.background, + observationIndex, + oneMinusAlpha, + alphaValue, + pixel, + ); + const recovered2 = recoverForegroundFromObservation( + input2.observation, + input2.background, + observationIndex, + oneMinusAlpha, + alphaValue, + pixel, + ); + + output[outputIndex] = Math.max(0, Math.min(1, (recovered1[0] + recovered2[0]) / 2)); + output[outputIndex + 1] = Math.max(0, Math.min(1, (recovered1[1] + recovered2[1]) / 2)); + output[outputIndex + 2] = Math.max(0, Math.min(1, (recovered1[2] + recovered2[2]) / 2)); + } + + return { + width: alpha.width, + height: alpha.height, + channels: 3, + format: "linear-rgb", + data: output, + }; +} + +function recoverForegroundFromObservation( + observation: LinearImageData, + background: readonly [number, number, number], + observationIndex: number, + oneMinusAlpha: number, + alphaValue: number, + pixel: number, +): readonly [number, number, number] { + validateObservationChannel(observation.data[observationIndex], "red", pixel); + validateObservationChannel(observation.data[observationIndex + 1], "green", pixel); + validateObservationChannel(observation.data[observationIndex + 2], "blue", pixel); + validateObservationChannel(observation.data[observationIndex + 3], "alpha", pixel); + + return [ + (observation.data[observationIndex] - oneMinusAlpha * background[0]) / alphaValue, + (observation.data[observationIndex + 1] - oneMinusAlpha * background[1]) / alphaValue, + (observation.data[observationIndex + 2] - oneMinusAlpha * background[2]) / alphaValue, + ]; +} + +function validateInput(input: ReconstructionInput, label: string): void { + validateObservation(input.observation, label); + validateBackground(input.background, label); +} + +function validateObservation(observation: LinearImageData, label: string): void { + if (observation.format !== EXPECTED_OBSERVATION_FORMAT) { + throw new ForegroundReconstructionError( + `${label} observation must have format ${EXPECTED_OBSERVATION_FORMAT}, got ${observation.format}.`, + ); + } + + if (observation.channels !== EXPECTED_OBSERVATION_CHANNELS) { + throw new ForegroundReconstructionError( + `${label} observation must have ${EXPECTED_OBSERVATION_CHANNELS} channels, got ${observation.channels}.`, + ); + } + + if (!Number.isInteger(observation.width) || observation.width <= 0) { + throw new ForegroundReconstructionError( + `${label} observation must have a positive integer width, got ${observation.width}.`, + ); + } + + if (!Number.isInteger(observation.height) || observation.height <= 0) { + throw new ForegroundReconstructionError( + `${label} observation must have a positive integer height, got ${observation.height}.`, + ); + } + + const expectedLength = observation.width * observation.height * EXPECTED_OBSERVATION_CHANNELS; + if (observation.data.length !== expectedLength) { + throw new ForegroundReconstructionError( + `${label} observation pixel buffer length ${observation.data.length} does not match expected ${expectedLength}.`, + ); + } +} + +function validateAlpha(alpha: AlphaChannelData): void { + if (alpha.format !== EXPECTED_ALPHA_FORMAT) { + throw new ForegroundReconstructionError( + `Alpha channel must have format ${EXPECTED_ALPHA_FORMAT}, got ${alpha.format}.`, + ); + } + + if (alpha.channels !== EXPECTED_ALPHA_CHANNELS) { + throw new ForegroundReconstructionError( + `Alpha channel must have ${EXPECTED_ALPHA_CHANNELS} channels, got ${alpha.channels}.`, + ); + } + + if (!Number.isInteger(alpha.width) || alpha.width <= 0) { + throw new ForegroundReconstructionError( + `Alpha channel must have a positive integer width, got ${alpha.width}.`, + ); + } + + if (!Number.isInteger(alpha.height) || alpha.height <= 0) { + throw new ForegroundReconstructionError( + `Alpha channel must have a positive integer height, got ${alpha.height}.`, + ); + } + + const expectedLength = alpha.width * alpha.height; + if (alpha.data.length !== expectedLength) { + throw new ForegroundReconstructionError( + `Alpha pixel buffer length ${alpha.data.length} does not match expected ${expectedLength}.`, + ); + } +} + +function validateBackground(background: readonly [number, number, number], label: string): void { + for (let channel = 0; channel < 3; channel++) { + const value = background[channel]; + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ForegroundReconstructionError( + `${label} background color value at index ${channel} must be in [0, 1], got ${value}.`, + ); + } + } +} + +function validateObservationChannel(value: number, channel: string, pixel: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ForegroundReconstructionError( + `Observation ${channel} value ${value} at pixel ${pixel} is outside the valid range [0, 1].`, + ); + } +} + +function validateAlphaChannel(value: number, pixel: number): void { + if (!Number.isFinite(value) || value < 0 || value > 1) { + throw new ForegroundReconstructionError( + `Alpha value ${value} at pixel ${pixel} is outside the valid range [0, 1].`, + ); + } +} diff --git a/src/reconstruction/foreground-types.ts b/src/reconstruction/foreground-types.ts new file mode 100644 index 0000000..4fc6a85 --- /dev/null +++ b/src/reconstruction/foreground-types.ts @@ -0,0 +1,39 @@ +import type { AlphaChannelData } from "./alpha-types.js"; +import type { ReconstructionInput } from "./reconstruction-types.js"; + +/** + * A reconstructed foreground image in linear RGB color space. + * + * Contains three channels per pixel (red, green, blue) in the range [0, 1]. + * The alpha channel is not included here; it remains an independent + * AlphaChannelData produced by the alpha reconstruction stage. + */ +export interface ForegroundImageData { + readonly width: number; + readonly height: number; + readonly channels: 3; + readonly format: "linear-rgb"; + readonly data: Float32Array; +} + +/** + * Options for {@link reconstructForeground}. + * + * Both observations and their corresponding backgrounds are required. The + * function treats them as equal inputs: it recovers a foreground estimate from + * each observation and averages the two results. This guarantees that the final + * foreground does not depend on observation order. + */ +export interface ReconstructForegroundOptions { + /** + * Two aligned linear RGB observations and their known backgrounds. + * + * The order of the two inputs does not affect the result. + */ + readonly inputs: readonly [ReconstructionInput, ReconstructionInput]; + + /** + * Reconstructed scalar alpha channel produced by {@link reconstructAlpha}. + */ + readonly alpha: AlphaChannelData; +} diff --git a/src/reconstruction/index.ts b/src/reconstruction/index.ts new file mode 100644 index 0000000..d6ade85 --- /dev/null +++ b/src/reconstruction/index.ts @@ -0,0 +1,8 @@ +export { reconstructAlpha } from "./alpha-reconstruction.js"; +export { AlphaReconstructionError } from "./alpha-errors.js"; +export type { ReconstructAlphaOptions, AlphaChannelData } from "./alpha-types.js"; +export type { ReconstructionInput } from "./reconstruction-types.js"; + +export { reconstructForeground } from "./foreground-reconstruction.js"; +export { ForegroundReconstructionError } from "./foreground-errors.js"; +export type { ReconstructForegroundOptions, ForegroundImageData } from "./foreground-types.js"; diff --git a/src/reconstruction/reconstruction-types.ts b/src/reconstruction/reconstruction-types.ts new file mode 100644 index 0000000..16e879b --- /dev/null +++ b/src/reconstruction/reconstruction-types.ts @@ -0,0 +1,12 @@ +import type { LinearImageData } from "../color/index.js"; + +/** + * A single observation and its known background for reconstruction stages. + * + * This type is shared by both Alpha Reconstruction and the future Foreground + * Reconstruction stage. + */ +export interface ReconstructionInput { + readonly observation: LinearImageData; + readonly background: readonly [number, number, number]; +} diff --git a/src/validation/background-errors.ts b/src/validation/background-errors.ts new file mode 100644 index 0000000..0f23be8 --- /dev/null +++ b/src/validation/background-errors.ts @@ -0,0 +1,58 @@ +import { ValidationError } from "./validation-errors.js"; +import type { MeasuredBackgroundColor } from "./background-validation.js"; + +/** + * Context attached to a background mismatch error. + */ +export interface BackgroundMismatchContext { + /** Declared linear RGB background color for the first observation. */ + readonly backgroundA: readonly [number, number, number]; + + /** Declared linear RGB background color for the second observation. */ + readonly backgroundB: readonly [number, number, number]; + + /** Measured background color for the first observation. */ + readonly measuredA: MeasuredBackgroundColor; + + /** Measured background color for the second observation. */ + readonly measuredB: MeasuredBackgroundColor; + + /** Linear RGB distance between declared and measured color for A. */ + readonly distanceA: number; + + /** Linear RGB distance between declared and measured color for B. */ + readonly distanceB: number; + + /** Threshold used for the mismatch decision. */ + readonly threshold: number; + + /** Path of the first observation image. */ + readonly pathA: string; + + /** Path of the second observation image. */ + readonly pathB: string; +} + +/** + * Error thrown when declared background colors are inconsistent with the colors + * measured from the image borders. + * + * This error is deterministic and contains the measured colors, declared + * colors, and distances so callers can diagnose the mismatch without rerunning + * the measurement. + */ +export class BackgroundMismatchError extends ValidationError { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly context?: BackgroundMismatchContext; + } = {}, + ) { + super(message, options); + this.name = "BackgroundMismatchError"; + this.context = options.context; + } + + readonly context?: BackgroundMismatchContext; +} diff --git a/src/validation/background-validation.ts b/src/validation/background-validation.ts new file mode 100644 index 0000000..e2767fb --- /dev/null +++ b/src/validation/background-validation.ts @@ -0,0 +1,331 @@ +import type { ImageData } from "../io/index.js"; +import { srgbToLinearChannel } from "../color/srgb.js"; +import { BackgroundMismatchError } from "./background-errors.js"; + +/** + * Default border width in pixels for background sampling. + * + * The border must be wide enough to capture typical background uniformity + * without including foreground pixels from the image interior. The value is + * an engineering default that may be refined by future benchmarking. + */ +export const DEFAULT_BACKGROUND_BORDER_WIDTH = 4; + +/** + * Default linear RGB distance threshold for declaring a background mismatch. + * + * The value is an initial engineering default. It should be reviewed against + * representative datasets before the public API is frozen in Milestone 8.6. + */ +export const DEFAULT_BACKGROUND_MISMATCH_THRESHOLD = 0.05; + +/** + * Metadata describing a background color measured from an image border. + */ +export interface MeasuredBackgroundColor { + /** Mean linear RGB color of the sampled border pixels. */ + readonly mean: readonly [number, number, number]; + + /** Per-channel variance of the sampled border pixels in linear RGB. */ + readonly variance: readonly [number, number, number]; + + /** Number of border pixels that contributed to the measurement. */ + readonly sampleCount: number; + + /** Minimum sampled linear RGB channel values. */ + readonly min: readonly [number, number, number]; + + /** Maximum sampled linear RGB channel values. */ + readonly max: readonly [number, number, number]; +} + +/** + * Result of validating declared background colors against measured border colors. + */ +export interface BackgroundValidationResult { + /** True when both measured colors are within the configured threshold. */ + readonly isValid: boolean; + + /** Measured background color for the first observation. */ + readonly measuredA: MeasuredBackgroundColor; + + /** Measured background color for the second observation. */ + readonly measuredB: MeasuredBackgroundColor; + + /** Linear RGB distance between the declared and measured color for A. */ + readonly distanceA: number; + + /** Linear RGB distance between the declared and measured color for B. */ + readonly distanceB: number; + + /** Threshold used to decide whether a mismatch is an error. */ + readonly threshold: number; +} + +/** + * Options for background validation. + */ +export interface BackgroundValidationOptions { + /** First observation image in RGBA8 sRGB color space. */ + readonly imageA: ImageData; + + /** Second observation image in RGBA8 sRGB color space. */ + readonly imageB: ImageData; + + /** Declared linear RGB background color for the first observation. */ + readonly backgroundA: readonly [number, number, number]; + + /** Declared linear RGB background color for the second observation. */ + readonly backgroundB: readonly [number, number, number]; + + /** + * Width of the border strip to sample, in pixels. + * + * Defaults to {@link DEFAULT_BACKGROUND_BORDER_WIDTH}. + */ + readonly borderWidth?: number; + + /** + * Maximum acceptable linear RGB distance between declared and measured + * background colors. + * + * Defaults to {@link DEFAULT_BACKGROUND_MISMATCH_THRESHOLD}. + */ + readonly threshold?: number; +} + +function clamp(value: number, min: number, max: number): number { + return Math.max(min, Math.min(max, value)); +} + +function toLinearNormalized(channel: number): number { + return srgbToLinearChannel(clamp(channel, 0, 255) / 255); +} + +function samplePixel(image: ImageData, x: number, y: number): readonly [number, number, number] { + const index = (y * image.width + x) * image.channels; + return [ + toLinearNormalized(image.data[index]), + toLinearNormalized(image.data[index + 1]), + toLinearNormalized(image.data[index + 2]), + ]; +} + +function addSample( + accumulator: { + sums: [number, number, number]; + squaredSums: [number, number, number]; + mins: [number, number, number]; + maxs: [number, number, number]; + count: number; + }, + color: readonly [number, number, number], +): void { + accumulator.sums[0] += color[0]; + accumulator.sums[1] += color[1]; + accumulator.sums[2] += color[2]; + + accumulator.squaredSums[0] += color[0] * color[0]; + accumulator.squaredSums[1] += color[1] * color[1]; + accumulator.squaredSums[2] += color[2] * color[2]; + + accumulator.mins[0] = Math.min(accumulator.mins[0], color[0]); + accumulator.mins[1] = Math.min(accumulator.mins[1], color[1]); + accumulator.mins[2] = Math.min(accumulator.mins[2], color[2]); + + accumulator.maxs[0] = Math.max(accumulator.maxs[0], color[0]); + accumulator.maxs[1] = Math.max(accumulator.maxs[1], color[1]); + accumulator.maxs[2] = Math.max(accumulator.maxs[2], color[2]); + + accumulator.count += 1; +} + +function createAccumulator(): { + sums: [number, number, number]; + squaredSums: [number, number, number]; + mins: [number, number, number]; + maxs: [number, number, number]; + count: number; +} { + return { + sums: [0, 0, 0], + squaredSums: [0, 0, 0], + mins: [Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY, Number.POSITIVE_INFINITY], + maxs: [Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY, Number.NEGATIVE_INFINITY], + count: 0, + }; +} + +function finalizeMeasurement( + accumulator: ReturnType, +): MeasuredBackgroundColor { + const count = accumulator.count; + + if (count === 0) { + return { + mean: [0, 0, 0], + variance: [0, 0, 0], + sampleCount: 0, + min: [0, 0, 0], + max: [0, 0, 0], + }; + } + + const mean: [number, number, number] = [ + accumulator.sums[0] / count, + accumulator.sums[1] / count, + accumulator.sums[2] / count, + ]; + + const variance: [number, number, number] = [ + Math.max(0, accumulator.squaredSums[0] / count - mean[0] * mean[0]), + Math.max(0, accumulator.squaredSums[1] / count - mean[1] * mean[1]), + Math.max(0, accumulator.squaredSums[2] / count - mean[2] * mean[2]), + ]; + + return { + mean, + variance, + sampleCount: count, + min: accumulator.mins, + max: accumulator.maxs, + }; +} + +/** + * Measure the background color of an image by sampling its border pixels. + * + * Samples the perimeter of the image to a configurable depth and returns the + * mean linear RGB color plus variance, sample count, and min/max sampled + * values. The measurement is deterministic and does not modify the input. + * + * @param image - The RGBA8 image to measure. + * @param borderWidth - Number of pixels to sample from each edge. Defaults to + * {@link DEFAULT_BACKGROUND_BORDER_WIDTH}. + * @returns Measured background color metadata. + */ +export function measureBackgroundColor( + image: ImageData, + borderWidth: number = DEFAULT_BACKGROUND_BORDER_WIDTH, +): MeasuredBackgroundColor { + const width = image.width; + const height = image.height; + const clampedBorderWidth = Math.max(1, Math.floor(borderWidth)); + const effectiveWidth = Math.max(1, Math.min(clampedBorderWidth, Math.floor(width / 2))); + const effectiveHeight = Math.max(1, Math.min(clampedBorderWidth, Math.floor(height / 2))); + + const accumulator = createAccumulator(); + + // When the requested border consumes the entire image height, the whole + // image is treated as the border and each pixel is sampled exactly once. + if (effectiveHeight * 2 >= height) { + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + addSample(accumulator, samplePixel(image, x, y)); + } + } + return finalizeMeasurement(accumulator); + } + + // Top and bottom rows. + for (let y = 0; y < effectiveHeight; y += 1) { + for (let x = 0; x < width; x += 1) { + addSample(accumulator, samplePixel(image, x, y)); + addSample(accumulator, samplePixel(image, x, height - 1 - y)); + } + } + + // Left and right columns, excluding the corners already covered above. + for (let y = effectiveHeight; y < height - effectiveHeight; y += 1) { + for (let x = 0; x < effectiveWidth; x += 1) { + addSample(accumulator, samplePixel(image, x, y)); + addSample(accumulator, samplePixel(image, width - 1 - x, y)); + } + } + + return finalizeMeasurement(accumulator); +} + +function linearRgbDistance( + a: readonly [number, number, number], + b: readonly [number, number, number], +): number { + const dr = a[0] - b[0]; + const dg = a[1] - b[1]; + const db = a[2] - b[2]; + return Math.sqrt(dr * dr + dg * dg + db * db); +} + +/** + * Validate that the declared background colors match the measured border colors. + * + * Compares the caller-provided linear RGB background colors against the mean + * linear RGB colors measured from the image borders. If the linear RGB distance + * for either observation exceeds the configured threshold, the function returns + * a result with `isValid: false`. + * + * The function does not modify the input images and does not infer or replace + * background colors. It only reports whether the declared values are consistent + * with the image content. + * + * @param options - Images, declared colors, and validation parameters. + * @returns Structured validation result. + */ +export function validateBackgroundColors( + options: BackgroundValidationOptions, +): BackgroundValidationResult { + const borderWidth = options.borderWidth ?? DEFAULT_BACKGROUND_BORDER_WIDTH; + const threshold = options.threshold ?? DEFAULT_BACKGROUND_MISMATCH_THRESHOLD; + + const measuredA = measureBackgroundColor(options.imageA, borderWidth); + const measuredB = measureBackgroundColor(options.imageB, borderWidth); + + const distanceA = linearRgbDistance(measuredA.mean, options.backgroundA); + const distanceB = linearRgbDistance(measuredB.mean, options.backgroundB); + + return { + isValid: distanceA <= threshold && distanceB <= threshold, + measuredA, + measuredB, + distanceA, + distanceB, + threshold, + }; +} + +/** + * Assert that declared background colors are consistent with measured border colors. + * + * Runs the same measurement as {@link validateBackgroundColors} and throws a + * {@link BackgroundMismatchError} when either observation exceeds the + * threshold. + * + * @param options - Images, declared colors, and validation parameters. + * @throws {BackgroundMismatchError} when a mismatch is detected. + */ +export function assertBackgroundColorsValid(options: BackgroundValidationOptions): void { + const result = validateBackgroundColors(options); + + if (result.isValid) { + return; + } + + throw new BackgroundMismatchError( + "Declared background colors do not match the measured border colors. " + + "AI-generated images often use backgrounds that differ from the requested colors. " + + "Verify the declared background colors or measure them from the image borders.", + { + context: { + backgroundA: options.backgroundA, + backgroundB: options.backgroundB, + measuredA: result.measuredA, + measuredB: result.measuredB, + distanceA: result.distanceA, + distanceB: result.distanceB, + threshold: result.threshold, + pathA: options.imageA.path, + pathB: options.imageB.path, + }, + }, + ); +} diff --git a/src/validation/dimension-validator.ts b/src/validation/dimension-validator.ts new file mode 100644 index 0000000..861238e --- /dev/null +++ b/src/validation/dimension-validator.ts @@ -0,0 +1,33 @@ +import type { ImageData } from "../io/index.js"; +import type { ValidationIssue } from "./validation-types.js"; + +/** + * Validate that two images have matching dimensions. + * + * @param a - First image. + * @param b - Second image. + * @returns A list of validation issues. Empty when dimensions match. + */ +export function validateImageDimensions(a: ImageData, b: ImageData): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + if (a.width !== b.width) { + issues.push({ + code: "DIMENSION_WIDTH_MISMATCH", + message: `Image widths do not match: ${a.width} vs ${b.width}`, + severity: "error", + validator: "dimensions", + }); + } + + if (a.height !== b.height) { + issues.push({ + code: "DIMENSION_HEIGHT_MISMATCH", + message: `Image heights do not match: ${a.height} vs ${b.height}`, + severity: "error", + validator: "dimensions", + }); + } + + return issues; +} diff --git a/src/validation/index.ts b/src/validation/index.ts new file mode 100644 index 0000000..f945b25 --- /dev/null +++ b/src/validation/index.ts @@ -0,0 +1,23 @@ +export { validateImageMetadata } from "./metadata-validator.js"; +export { validateImageDimensions } from "./dimension-validator.js"; +export { validateImages, assertImagesValid } from "./validate-images.js"; +export type { ValidationIssue, ValidationResult } from "./validation-types.js"; +export { + ValidationError, + MetadataValidationError, + DimensionValidationError, +} from "./validation-errors.js"; +export { + measureBackgroundColor, + validateBackgroundColors, + assertBackgroundColorsValid, + DEFAULT_BACKGROUND_BORDER_WIDTH, + DEFAULT_BACKGROUND_MISMATCH_THRESHOLD, +} from "./background-validation.js"; +export type { + MeasuredBackgroundColor, + BackgroundValidationOptions, + BackgroundValidationResult, +} from "./background-validation.js"; +export { BackgroundMismatchError } from "./background-errors.js"; +export type { BackgroundMismatchContext } from "./background-errors.js"; diff --git a/src/validation/metadata-validator.ts b/src/validation/metadata-validator.ts new file mode 100644 index 0000000..bb45b89 --- /dev/null +++ b/src/validation/metadata-validator.ts @@ -0,0 +1,63 @@ +import type { ImageData } from "../io/index.js"; +import type { ValidationIssue } from "./validation-types.js"; + +/** + * Validate the metadata of a single normalized image. + * + * Checks that the image has positive integer dimensions, four RGBA8 channels, + * the expected pixel format, and a pixel buffer that matches the declared size. + * + * @param image - The image to validate. + * @returns A list of validation issues. Empty when the image is valid. + */ +export function validateImageMetadata(image: ImageData): ValidationIssue[] { + const issues: ValidationIssue[] = []; + + if (!Number.isInteger(image.width) || image.width <= 0) { + issues.push({ + code: "METADATA_INVALID_WIDTH", + message: `Image width must be a positive integer, got ${image.width}`, + severity: "error", + validator: "metadata", + }); + } + + if (!Number.isInteger(image.height) || image.height <= 0) { + issues.push({ + code: "METADATA_INVALID_HEIGHT", + message: `Image height must be a positive integer, got ${image.height}`, + severity: "error", + validator: "metadata", + }); + } + + if (image.channels !== 4) { + issues.push({ + code: "METADATA_INVALID_CHANNELS", + message: `Image must have exactly 4 channels (RGBA8), got ${image.channels}`, + severity: "error", + validator: "metadata", + }); + } + + if (image.format !== "rgba8") { + issues.push({ + code: "METADATA_INVALID_FORMAT", + message: `Image must be in rgba8 format, got ${image.format}`, + severity: "error", + validator: "metadata", + }); + } + + const expectedSize = image.width * image.height * image.channels; + if (image.data.length !== expectedSize) { + issues.push({ + code: "METADATA_BUFFER_SIZE_MISMATCH", + message: `Pixel buffer size ${image.data.length} does not match expected size ${expectedSize} for ${image.width}x${image.height}x${image.channels}`, + severity: "error", + validator: "metadata", + }); + } + + return issues; +} diff --git a/src/validation/validate-images.ts b/src/validation/validate-images.ts new file mode 100644 index 0000000..d21e190 --- /dev/null +++ b/src/validation/validate-images.ts @@ -0,0 +1,70 @@ +import type { ImageData } from "../io/index.js"; +import { + DimensionValidationError, + MetadataValidationError, + ValidationError, +} from "./validation-errors.js"; +import type { ValidationIssue, ValidationResult } from "./validation-types.js"; +import { validateImageDimensions } from "./dimension-validator.js"; +import { validateImageMetadata } from "./metadata-validator.js"; + +/** + * Validate a pair of images for metadata consistency and dimension compatibility. + * + * Returns a structured result with all discovered issues. This function never + * modifies the input images. + * + * @param a - First image. + * @param b - Second image. + * @returns Structured validation result. + */ +export function validateImages(a: ImageData, b: ImageData): ValidationResult { + const issues: ValidationIssue[] = [ + ...validateImageMetadata(a), + ...validateImageMetadata(b), + ...validateImageDimensions(a, b), + ]; + + return { + isValid: issues.length === 0, + issues, + }; +} + +/** + * Assert that a pair of images is valid. + * + * Throws a specific error type when validation fails: + * - {@link DimensionValidationError} for dimension mismatches. + * - {@link MetadataValidationError} for metadata issues. + * - {@link ValidationError} for any other failure. + * + * @param a - First image. + * @param b - Second image. + * @throws {ValidationError} when validation fails. + */ +export function assertImagesValid(a: ImageData, b: ImageData): void { + const result = validateImages(a, b); + if (result.isValid) { + return; + } + + const metadataIssues = result.issues.filter((issue) => issue.validator === "metadata"); + const dimensionIssues = result.issues.filter((issue) => issue.validator === "dimensions"); + + if (metadataIssues.length > 0) { + throw new MetadataValidationError("Image metadata is invalid", { + issues: metadataIssues, + }); + } + + if (dimensionIssues.length > 0) { + throw new DimensionValidationError("Image dimensions are invalid", { + issues: dimensionIssues, + }); + } + + throw new ValidationError("Image validation failed", { + issues: result.issues, + }); +} diff --git a/src/validation/validation-errors.ts b/src/validation/validation-errors.ts new file mode 100644 index 0000000..f648bc3 --- /dev/null +++ b/src/validation/validation-errors.ts @@ -0,0 +1,56 @@ +import type { ValidationIssue } from "./validation-types.js"; + +/** + * Base error for all validation failures. + * + * Contains the full list of issues that caused the validation to fail. + */ +export class ValidationError extends Error { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly issues?: readonly ValidationIssue[]; + } = {}, + ) { + super(message); + this.name = "ValidationError"; + this.cause = options.cause; + this.issues = options.issues ?? []; + } + + readonly cause?: unknown; + readonly issues: readonly ValidationIssue[]; +} + +/** + * Error thrown when one or both images have invalid metadata. + */ +export class MetadataValidationError extends ValidationError { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly issues?: readonly ValidationIssue[]; + } = {}, + ) { + super(message, options); + this.name = "MetadataValidationError"; + } +} + +/** + * Error thrown when a pair of images have incompatible dimensions. + */ +export class DimensionValidationError extends ValidationError { + constructor( + message: string, + options: { + readonly cause?: unknown; + readonly issues?: readonly ValidationIssue[]; + } = {}, + ) { + super(message, options); + this.name = "DimensionValidationError"; + } +} diff --git a/src/validation/validation-types.ts b/src/validation/validation-types.ts new file mode 100644 index 0000000..1af7a42 --- /dev/null +++ b/src/validation/validation-types.ts @@ -0,0 +1,29 @@ +export type Severity = "error" | "warning"; + +/** + * A single validation issue discovered by a validator. + */ +export interface ValidationIssue { + /** Machine-readable identifier for the issue. */ + readonly code: string; + + /** Human-readable description of the issue. */ + readonly message: string; + + /** Severity of the issue. */ + readonly severity: Severity; + + /** Name of the validator that produced this issue. */ + readonly validator: string; +} + +/** + * Structured result of validating a pair of images. + */ +export interface ValidationResult { + /** True when no issues were found. */ + readonly isValid: boolean; + + /** All issues discovered during validation. */ + readonly issues: readonly ValidationIssue[]; +} diff --git a/tests/cleanup/alpha-thresholding.test.ts b/tests/cleanup/alpha-thresholding.test.ts new file mode 100644 index 0000000..06335b1 --- /dev/null +++ b/tests/cleanup/alpha-thresholding.test.ts @@ -0,0 +1,157 @@ +import { describe, expect, it } from "vitest"; +import { applyAlphaThresholding } from "../../src/cleanup/alpha-thresholding.js"; +import { CleanupError } from "../../src/cleanup/cleanup-errors.js"; +import { createAlphaChannelData } from "./helpers.js"; + +describe("applyAlphaThresholding", () => { + it("sets alpha below alphaLow to 0", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([0.04]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBe(0); + }); + + it("sets alpha above alphaHigh to 1", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([0.96]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBe(1); + }); + + it("preserves alpha between alphaLow and alphaHigh", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([0.5]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBe(0.5); + }); + + it("uses strict inequalities for the threshold boundaries", () => { + const alpha = createAlphaChannelData({ + width: 3, + height: 1, + data: new Float32Array([0.05, 0.5, 0.95]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBeCloseTo(0.05, 6); + expect(result.data[1]).toBeCloseTo(0.5, 6); + expect(result.data[2]).toBeCloseTo(0.95, 6); + }); + + it("applies thresholding to every pixel independently", () => { + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0.01, 0.99, 0.5, 0.0]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBe(0); + expect(result.data[1]).toBe(1); + expect(result.data[2]).toBe(0.5); + expect(result.data[3]).toBe(0); + }); + + it("does not mutate the input alpha buffer", () => { + const data = new Float32Array([0.04, 0.96, 0.5]); + const alpha = createAlphaChannelData({ + width: 3, + height: 1, + data, + }); + + applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(alpha.data).toEqual(new Float32Array([0.04, 0.96, 0.5])); + expect(data).toEqual(new Float32Array([0.04, 0.96, 0.5])); + }); + + it("returns a new AlphaChannelData with the same dimensions", () => { + const alpha = createAlphaChannelData({ + width: 2, + height: 3, + data: new Float32Array(6), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.width).toBe(2); + expect(result.height).toBe(3); + expect(result.channels).toBe(1); + expect(result.format).toBe("alpha"); + expect(result.data).toBeInstanceOf(Float32Array); + expect(result.data.length).toBe(6); + }); + + it("throws when alphaLow is negative", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyAlphaThresholding(alpha, { alphaLow: -0.01, alphaHigh: 0.95 })).toThrow( + CleanupError, + ); + }); + + it("throws when alphaHigh is greater than 1", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 1.01 })).toThrow( + CleanupError, + ); + }); + + it("throws when alphaLow is greater than alphaHigh", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyAlphaThresholding(alpha, { alphaLow: 0.95, alphaHigh: 0.05 })).toThrow( + CleanupError, + ); + }); + + it("throws when alphaLow is not finite", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyAlphaThresholding(alpha, { alphaLow: Number.NaN, alphaHigh: 0.95 })).toThrow( + CleanupError, + ); + }); + + it("throws when alphaHigh is not finite", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: Number.NaN })).toThrow( + CleanupError, + ); + }); + + it("preserves geometry by clamping only at the configured thresholds", () => { + const alpha = createAlphaChannelData({ + width: 4, + height: 1, + data: new Float32Array([0.049, 0.051, 0.949, 0.951]), + }); + + const result = applyAlphaThresholding(alpha, { alphaLow: 0.05, alphaHigh: 0.95 }); + + expect(result.data[0]).toBe(0); + expect(result.data[1]).toBeCloseTo(0.051, 6); + expect(result.data[2]).toBeCloseTo(0.949, 6); + expect(result.data[3]).toBe(1); + }); +}); diff --git a/tests/cleanup/cleanup-integration.test.ts b/tests/cleanup/cleanup-integration.test.ts new file mode 100644 index 0000000..30789d0 --- /dev/null +++ b/tests/cleanup/cleanup-integration.test.ts @@ -0,0 +1,268 @@ +import { describe, expect, it } from "vitest"; +import { reconstructAlpha, reconstructForeground } from "../../src/reconstruction/index.js"; +import { cleanup } from "../../src/cleanup/cleanup.js"; +import { + composeObservation, + constantAlpha, + constantForeground, +} from "../reconstruction/helpers.js"; +import type { LinearImageData } from "../../src/color/index.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function reconstructAlphaFromObservations( + observation1: LinearImageData, + observation2: LinearImageData, +) { + return reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); +} + +describe("cleanup integration with reconstruction pipeline", () => { + it("chains reconstructAlpha -> reconstructForeground -> cleanup deterministically", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const reconstructedForeground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha: reconstructedAlpha, + }); + + const cleaned = cleanup({ + alpha: reconstructedAlpha, + foreground: reconstructedForeground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + noiseRemoval: { maxArtifactSize: 2, connectivity: 4 }, + morphology: { closing: { shape: "square", radius: 1 } }, + }); + + expect(cleaned.alpha.width).toBe(4); + expect(cleaned.alpha.height).toBe(4); + expect(cleaned.alpha.channels).toBe(1); + expect(cleaned.alpha.format).toBe("alpha"); + expect(cleaned.foreground).toBeDefined(); + expect(cleaned.foreground!.width).toBe(4); + expect(cleaned.foreground!.height).toBe(4); + expect(cleaned.foreground!.channels).toBe(3); + expect(cleaned.foreground!.format).toBe("linear-rgb"); + }); + + it("produces deterministic results for identical reconstruction inputs", () => { + const foreground: readonly [number, number, number] = [0.3, 0.6, 0.9]; + const alpha = 0.4; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const run = () => { + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const reconstructedForeground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha: reconstructedAlpha, + }); + return cleanup({ + alpha: reconstructedAlpha, + foreground: reconstructedForeground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + }; + + const first = run(); + const second = run(); + + expect(first.alpha.data).toEqual(second.alpha.data); + expect(first.foreground!.data).toEqual(second.foreground!.data); + }); + + it("does not mutate outputs from reconstruction", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const reconstructedForeground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha: reconstructedAlpha, + }); + + const originalAlpha = new Float32Array(reconstructedAlpha.data); + const originalForeground = new Float32Array(reconstructedForeground.data); + + cleanup({ + alpha: reconstructedAlpha, + foreground: reconstructedForeground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + noiseRemoval: { maxArtifactSize: 2, connectivity: 4 }, + morphology: { closing: { shape: "square", radius: 1 } }, + }); + + expect(reconstructedAlpha.data).toEqual(originalAlpha); + expect(reconstructedForeground.data).toEqual(originalForeground); + }); + + it("does not mutate the original observations", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const originalObservation1 = new Float32Array(observation1.data); + const originalObservation2 = new Float32Array(observation2.data); + + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const reconstructedForeground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha: reconstructedAlpha, + }); + cleanup({ + alpha: reconstructedAlpha, + foreground: reconstructedForeground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + expect(observation1.data).toEqual(originalObservation1); + expect(observation2.data).toEqual(originalObservation2); + }); + + it("accepts only the alpha channel without requiring a foreground image", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const result = cleanup({ + alpha: reconstructedAlpha, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + expect(result.alpha.width).toBe(4); + expect(result.alpha.height).toBe(4); + expect(result.foreground).toBeUndefined(); + }); + + it("rejects mismatched dimensions between alpha and foreground", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const reconstructedAlpha = reconstructAlphaFromObservations(observation1, observation2); + const reconstructedForeground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha: reconstructedAlpha, + }); + + const mismatchedAlpha = { + ...reconstructedAlpha, + width: 2, + height: 2, + }; + + expect(() => + cleanup({ + alpha: mismatchedAlpha, + foreground: reconstructedForeground, + }), + ).toThrow(); + }); +}); diff --git a/tests/cleanup/cleanup-properties.test.ts b/tests/cleanup/cleanup-properties.test.ts new file mode 100644 index 0000000..6182ff6 --- /dev/null +++ b/tests/cleanup/cleanup-properties.test.ts @@ -0,0 +1,354 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { cleanup } from "../../src/cleanup/cleanup.js"; +import { applyAlphaThresholding } from "../../src/cleanup/alpha-thresholding.js"; +import { applyNoiseRemoval } from "../../src/cleanup/noise-removal.js"; +import { + applyMorphologicalClosing, + applyMorphologicalOpening, +} from "../../src/cleanup/morphology.js"; +import type { AlphaChannelData } from "../../src/reconstruction/index.js"; +import type { + AlphaThresholdOptions, + MorphologyOptions, + NoiseRemovalOptions, + StructuringElementOptions, +} from "../../src/cleanup/cleanup-types.js"; + +function alphaArbitrary(width: number, height: number): fc.Arbitrary { + return fc + .array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: width * height, + maxLength: width * height, + }) + .map((values) => ({ + width, + height, + channels: 1, + format: "alpha" as const, + data: new Float32Array(values), + })); +} + +function alphaThresholdOptionsArbitrary(): fc.Arbitrary { + return fc + .tuple(fc.float({ min: 0, max: 1, noNaN: true }), fc.float({ min: 0, max: 1, noNaN: true })) + .filter(([low, high]) => low <= high) + .map(([alphaLow, alphaHigh]) => ({ alphaLow, alphaHigh })); +} + +function noiseRemovalOptionsArbitrary(): fc.Arbitrary { + return fc.record({ + maxArtifactSize: fc.integer({ min: 1, max: 10 }), + connectivity: fc.constantFrom(4, 8), + }); +} + +function structuringElementOptionsArbitrary(): fc.Arbitrary { + return fc.record({ + shape: fc.constantFrom("square", "disk", "cross"), + radius: fc.integer({ min: 0, max: 2 }), + }); +} + +function morphologyOptionsArbitrary(): fc.Arbitrary { + return fc.record({ + opening: fc.option(structuringElementOptionsArbitrary(), { nil: undefined }), + closing: fc.option(structuringElementOptionsArbitrary(), { nil: undefined }), + }); +} + +function assertFloat32ArrayInRange(data: Float32Array): void { + for (const value of data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } +} + +function assertAlphaChannelInvariant(alpha: AlphaChannelData): void { + expect(Number.isInteger(alpha.width)).toBe(true); + expect(alpha.width).toBeGreaterThan(0); + expect(Number.isInteger(alpha.height)).toBe(true); + expect(alpha.height).toBeGreaterThan(0); + expect(alpha.channels).toBe(1); + expect(alpha.format).toBe("alpha"); + expect(alpha.data).toBeInstanceOf(Float32Array); + expect(alpha.data.length).toBe(alpha.width * alpha.height); + assertFloat32ArrayInRange(alpha.data); +} + +/** + * Documented idempotent pipeline configuration: + * thresholding -> noise removal -> morphological closing. + */ +const IDEMPOTENT_CONFIG = { + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + noiseRemoval: { maxArtifactSize: 3, connectivity: 4 as const }, + morphology: { closing: { shape: "square" as const, radius: 1 } }, +}; + +describe("cleanup property-based invariants", () => { + it("determinism: identical inputs produce bit-identical outputs", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + alphaArbitrary(width, height).map((alpha) => ({ alpha, width, height })), + ), + ), + ({ alpha }) => { + const resultA = cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + const resultB = cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + expect(resultA.alpha.data).toEqual(resultB.alpha.data); + expect(resultA.alpha.width).toBe(resultB.alpha.width); + expect(resultA.alpha.height).toBe(resultB.alpha.height); + }, + ), + ); + }); + + it("idempotence: cleanup(cleanup(x)) equals cleanup(x) for the documented configuration", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + alphaArbitrary(width, height).map((alpha) => ({ alpha, width, height })), + ), + ), + ({ alpha }) => { + const once = cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + const twice = cleanup({ alpha: once.alpha, ...IDEMPOTENT_CONFIG }); + expect(twice.alpha.data).toEqual(once.alpha.data); + }, + ), + ); + }); + + it("alpha values remain within [0, 1] after cleanup", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + alphaArbitrary(width, height).map((alpha) => ({ alpha, width, height })), + ), + ), + ({ alpha }) => { + const result = cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + assertFloat32ArrayInRange(result.alpha.data); + }, + ), + ); + }); + + it("cleanup preserves alpha channel dimensions and buffer size", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + alphaArbitrary(width, height).map((alpha) => ({ alpha, width, height })), + ), + ), + ({ alpha, width, height }) => { + const result = cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + expect(result.alpha.width).toBe(width); + expect(result.alpha.height).toBe(height); + expect(result.alpha.data.length).toBe(width * height); + }, + ), + ); + }); + + it("cleanup does not mutate its input alpha buffer", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 8 }).chain((width) => + fc.integer({ min: 1, max: 8 }).chain((height) => + alphaArbitrary(width, height).map((alpha) => { + const original = new Float32Array(alpha.data); + return { alpha, original }; + }), + ), + ), + ({ alpha, original }) => { + cleanup({ alpha, ...IDEMPOTENT_CONFIG }); + expect(alpha.data).toEqual(original); + }, + ), + ); + }); + + it("alpha thresholding is idempotent", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), alphaThresholdOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const once = applyAlphaThresholding(alpha, options); + const twice = applyAlphaThresholding(once, options); + expect(twice.data).toEqual(once.data); + }, + ), + ); + }); + + it("alpha thresholding preserves alpha channel invariants", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), alphaThresholdOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const result = applyAlphaThresholding(alpha, options); + assertAlphaChannelInvariant(result); + }, + ), + ); + }); + + it("noise removal is idempotent", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), noiseRemovalOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const once = applyNoiseRemoval(alpha, options); + const twice = applyNoiseRemoval(once, options); + expect(twice.data).toEqual(once.data); + }, + ), + ); + }); + + it("noise removal preserves alpha channel invariants", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), noiseRemovalOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const result = applyNoiseRemoval(alpha, options); + assertAlphaChannelInvariant(result); + }, + ), + ); + }); + + it("morphological opening is idempotent", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), structuringElementOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const once = applyMorphologicalOpening(alpha, options); + const twice = applyMorphologicalOpening(once, options); + expect(twice.data).toEqual(once.data); + }, + ), + ); + }); + + it("morphological closing is idempotent", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), structuringElementOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const once = applyMorphologicalClosing(alpha, options); + const twice = applyMorphologicalClosing(once, options); + expect(twice.data).toEqual(once.data); + }, + ), + ); + }); + + it("morphological operations preserve alpha channel invariants", () => { + fc.assert( + fc.property( + fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc + .integer({ min: 1, max: 8 }) + .chain((height) => + fc + .tuple(alphaArbitrary(width, height), morphologyOptionsArbitrary()) + .map(([alpha, options]) => ({ alpha, options })), + ), + ), + ({ alpha, options }) => { + const result = cleanup({ alpha, morphology: options }); + assertAlphaChannelInvariant(result.alpha); + }, + ), + ); + }); +}); diff --git a/tests/cleanup/cleanup.test.ts b/tests/cleanup/cleanup.test.ts new file mode 100644 index 0000000..e978744 --- /dev/null +++ b/tests/cleanup/cleanup.test.ts @@ -0,0 +1,214 @@ +import { describe, expect, it } from "vitest"; +import { cleanup } from "../../src/cleanup/cleanup.js"; +import { CleanupError } from "../../src/cleanup/cleanup-errors.js"; +import { createAlphaChannelData, createForegroundImageData } from "./helpers.js"; + +describe("cleanup", () => { + it("returns an unchanged alpha channel when no stages are configured", () => { + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0.1, 0.5, 0.9, 0.0]), + }); + + const result = cleanup({ alpha }); + + expect(result.alpha.data).toEqual(alpha.data); + expect(result.alpha.width).toBe(2); + expect(result.alpha.height).toBe(2); + }); + + it("applies alpha thresholding when configured", () => { + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0.01, 0.5, 0.99, 0.0]), + }); + + const result = cleanup({ + alpha, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + expect(result.alpha.data[0]).toBe(0); + expect(result.alpha.data[1]).toBe(0.5); + expect(result.alpha.data[2]).toBe(1); + expect(result.alpha.data[3]).toBe(0); + }); + + it("applies noise removal when configured", () => { + const data = new Float32Array(9).fill(0); + data[1 * 3 + 1] = 1; + const alpha = createAlphaChannelData({ width: 3, height: 3, data }); + + const result = cleanup({ + alpha, + noiseRemoval: { maxArtifactSize: 2, connectivity: 4 }, + }); + + expect(result.alpha.data[1 * 3 + 1]).toBe(0); + }); + + it("applies morphological opening when configured", () => { + const data = new Float32Array([0, 1, 0, 0, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = cleanup({ + alpha, + morphology: { opening: { shape: "square", radius: 1 } }, + }); + + expect(result.alpha.data).toEqual(new Float32Array([0, 0, 0, 0, 0])); + }); + + it("applies morphological closing when configured", () => { + const data = new Float32Array([1, 1, 0, 1, 1]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = cleanup({ + alpha, + morphology: { closing: { shape: "square", radius: 1 } }, + }); + + expect(result.alpha.data).toEqual(new Float32Array([1, 1, 1, 1, 1])); + }); + + it("runs stages in the fixed order: threshold, noise removal, morphology", () => { + // 3x3 image: one small isolated pixel above threshold, one small hole below threshold + const data = new Float32Array(9).fill(0); + data[0] = 0.99; + data[1] = 0.02; + data[2] = 0.99; + const alpha = createAlphaChannelData({ width: 3, height: 3, data }); + + const result = cleanup({ + alpha, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + noiseRemoval: { maxArtifactSize: 2, connectivity: 4 }, + morphology: { closing: { shape: "square", radius: 1 } }, + }); + + // Threshold: 0.02 -> 0, 0.99 -> 1 + // After threshold: [1,0,1, 0,0,0, 0,0,0] + // Noise removal: remove isolated 1s -> [0,0,0, 0,0,0, 0,0,0] + // Closing: unchanged + expect(result.alpha.data).toEqual(new Float32Array(9).fill(0)); + }); + + it("does not mutate the input alpha buffer", () => { + const data = new Float32Array([0.01, 0.5, 0.99, 0.0]); + const alpha = createAlphaChannelData({ width: 2, height: 2, data }); + const original = new Float32Array(alpha.data); + + cleanup({ + alpha, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + expect(alpha.data).toEqual(original); + }); + + it("passes the foreground image through unchanged", () => { + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0.01, 0.5, 0.99, 0.0]), + }); + const foreground = createForegroundImageData({ + width: 2, + height: 2, + data: new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 0.95, 0.85]), + }); + const originalForeground = new Float32Array(foreground.data); + + const result = cleanup({ + alpha, + foreground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + expect(result.foreground).toBeDefined(); + expect(result.foreground!.data).toEqual(originalForeground); + expect(result.foreground!.width).toBe(2); + expect(result.foreground!.height).toBe(2); + }); + + it("throws when alpha and foreground dimensions do not match", () => { + const alpha = createAlphaChannelData({ width: 2, height: 2 }); + const foreground = createForegroundImageData({ width: 3, height: 2 }); + + expect(() => + cleanup({ + alpha, + foreground, + }), + ).toThrow(CleanupError); + }); + + it("throws when the alpha channel contains values outside [0, 1]", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1.5]), + }); + + expect(() => cleanup({ alpha })).toThrow(CleanupError); + }); + + it("throws when the alpha channel contains non-finite values", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([Number.NaN]), + }); + + expect(() => cleanup({ alpha })).toThrow(CleanupError); + }); + + it("throws when the foreground image has invalid dimensions", () => { + const alpha = createAlphaChannelData({ width: 2, height: 2 }); + const foreground = { + width: 2, + height: 2, + channels: 3, + format: "linear-rgb" as const, + data: new Float32Array(11), + }; + + expect(() => cleanup({ alpha, foreground })).toThrow(CleanupError); + }); + + it("throws when the foreground image contains values outside [0, 1]", () => { + const alpha = createAlphaChannelData({ width: 2, height: 2 }); + const foreground = createForegroundImageData({ + width: 2, + height: 2, + data: new Float32Array([1.1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + }); + + expect(() => cleanup({ alpha, foreground })).toThrow(CleanupError); + }); + + it("throws when the foreground image contains non-finite values", () => { + const alpha = createAlphaChannelData({ width: 2, height: 2 }); + const foreground = createForegroundImageData({ + width: 2, + height: 2, + data: new Float32Array([Number.NaN, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0]), + }); + + expect(() => cleanup({ alpha, foreground })).toThrow(CleanupError); + }); + + it("returns a new alpha channel even when no stages are configured", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([0.5]), + }); + + const result = cleanup({ alpha }); + + expect(result.alpha.data).not.toBe(alpha.data); + }); +}); diff --git a/tests/cleanup/helpers.ts b/tests/cleanup/helpers.ts new file mode 100644 index 0000000..2996e0a --- /dev/null +++ b/tests/cleanup/helpers.ts @@ -0,0 +1,52 @@ +import type { AlphaChannelData } from "../../src/reconstruction/index.js"; +import type { ForegroundImageData } from "../../src/reconstruction/index.js"; + +/** + * Create a minimal AlphaChannelData object for cleanup tests. + */ +export function createAlphaChannelData(options: { + width: number; + height: number; + data?: Float32Array; +}): AlphaChannelData { + const { width, height } = options; + const pixelCount = width * height; + const data = options.data ?? new Float32Array(pixelCount); + + if (data.length !== pixelCount) { + throw new Error("Alpha data length does not match dimensions"); + } + + return { + width, + height, + channels: 1, + format: "alpha", + data, + }; +} + +/** + * Create a minimal ForegroundImageData object for cleanup tests. + */ +export function createForegroundImageData(options: { + width: number; + height: number; + data?: Float32Array; +}): ForegroundImageData { + const { width, height } = options; + const pixelCount = width * height; + const data = options.data ?? new Float32Array(pixelCount * 3); + + if (data.length !== pixelCount * 3) { + throw new Error("Foreground data length does not match dimensions"); + } + + return { + width, + height, + channels: 3, + format: "linear-rgb", + data, + }; +} diff --git a/tests/cleanup/morphology.test.ts b/tests/cleanup/morphology.test.ts new file mode 100644 index 0000000..4ed2319 --- /dev/null +++ b/tests/cleanup/morphology.test.ts @@ -0,0 +1,153 @@ +import { describe, expect, it } from "vitest"; +import { + applyMorphologicalClosing, + applyMorphologicalOpening, +} from "../../src/cleanup/morphology.js"; +import { CleanupError } from "../../src/cleanup/cleanup-errors.js"; +import { createAlphaChannelData } from "./helpers.js"; + +describe("applyMorphologicalOpening", () => { + it("removes small protrusions with a square structuring element", () => { + // 5x1 image: a single pixel protrusion + // 0 1 0 0 0 + const data = new Float32Array([0, 1, 0, 0, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalOpening(alpha, { shape: "square", radius: 1 }); + + // Opening with radius 1 should remove the single-pixel protrusion + expect(result.data).toEqual(new Float32Array([0, 0, 0, 0, 0])); + }); + + it("does not affect large uniform regions", () => { + const data = new Float32Array([1, 1, 1, 1, 1]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalOpening(alpha, { shape: "square", radius: 1 }); + + expect(result.data).toEqual(new Float32Array([1, 1, 1, 1, 1])); + }); + + it("does not mutate the input alpha buffer", () => { + const data = new Float32Array([0, 1, 1, 1, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + const original = new Float32Array(alpha.data); + + applyMorphologicalOpening(alpha, { shape: "square", radius: 1 }); + + expect(alpha.data).toEqual(original); + }); + + it("returns a new AlphaChannelData with the same dimensions", () => { + const data = new Float32Array([0, 1, 1, 1, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalOpening(alpha, { shape: "square", radius: 1 }); + + expect(result.width).toBe(5); + expect(result.height).toBe(1); + expect(result.channels).toBe(1); + expect(result.format).toBe("alpha"); + expect(result.data).toBeInstanceOf(Float32Array); + }); + + it("produces deterministic output for disk and cross shapes", () => { + const data = new Float32Array([0, 1, 0, 0, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const diskResult = applyMorphologicalOpening(alpha, { shape: "disk", radius: 1 }); + const crossResult = applyMorphologicalOpening(alpha, { shape: "cross", radius: 1 }); + + expect(diskResult.data).toEqual(crossResult.data); + expect(diskResult.data).toEqual(new Float32Array([0, 0, 0, 0, 0])); + }); +}); + +describe("applyMorphologicalClosing", () => { + it("fills small holes with a square structuring element", () => { + // 5x1 image: a small hole inside a large region + // 1 1 0 1 1 + const data = new Float32Array([1, 1, 0, 1, 1]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalClosing(alpha, { shape: "square", radius: 1 }); + + expect(result.data).toEqual(new Float32Array([1, 1, 1, 1, 1])); + }); + + it("does not affect large uniform regions", () => { + const data = new Float32Array([0, 0, 0, 0, 0]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalClosing(alpha, { shape: "square", radius: 1 }); + + expect(result.data).toEqual(new Float32Array([0, 0, 0, 0, 0])); + }); + + it("does not mutate the input alpha buffer", () => { + const data = new Float32Array([1, 1, 0, 1, 1]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + const original = new Float32Array(alpha.data); + + applyMorphologicalClosing(alpha, { shape: "square", radius: 1 }); + + expect(alpha.data).toEqual(original); + }); + + it("returns a new AlphaChannelData with the same dimensions", () => { + const data = new Float32Array([1, 1, 0, 1, 1]); + const alpha = createAlphaChannelData({ width: 5, height: 1, data }); + + const result = applyMorphologicalClosing(alpha, { shape: "square", radius: 1 }); + + expect(result.width).toBe(5); + expect(result.height).toBe(1); + expect(result.channels).toBe(1); + expect(result.format).toBe("alpha"); + expect(result.data).toBeInstanceOf(Float32Array); + }); +}); + +describe("morphological validation", () => { + it("throws when radius is not a non-negative integer", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyMorphologicalOpening(alpha, { shape: "square", radius: -1 })).toThrow( + CleanupError, + ); + expect(() => applyMorphologicalOpening(alpha, { shape: "square", radius: 1.5 })).toThrow( + CleanupError, + ); + }); + + it("throws when shape is not supported", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => + applyMorphologicalOpening(alpha, { shape: "triangle" as "square", radius: 1 }), + ).toThrow(CleanupError); + }); + + it("throws when alpha channel is invalid", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + alpha.data = new Float32Array(2); + + expect(() => applyMorphologicalOpening(alpha, { shape: "square", radius: 1 })).toThrow( + CleanupError, + ); + }); +}); + +describe("morphological edge handling", () => { + it("only considers pixels within image bounds", () => { + // Single pixel at the corner of a 3x3 image + const data = new Float32Array(9).fill(0); + data[0] = 1; + const alpha = createAlphaChannelData({ width: 3, height: 3, data }); + + const opened = applyMorphologicalOpening(alpha, { shape: "square", radius: 1 }); + + // Opening with radius 1 at the corner should remove the single pixel + expect(opened.data[0]).toBe(0); + }); +}); diff --git a/tests/cleanup/noise-removal.test.ts b/tests/cleanup/noise-removal.test.ts new file mode 100644 index 0000000..5fe806e --- /dev/null +++ b/tests/cleanup/noise-removal.test.ts @@ -0,0 +1,151 @@ +import { describe, expect, it } from "vitest"; +import type { AlphaChannelData } from "../../src/reconstruction/index.js"; +import { applyNoiseRemoval } from "../../src/cleanup/noise-removal.js"; +import { CleanupError } from "../../src/cleanup/cleanup-errors.js"; +import { createAlphaChannelData } from "./helpers.js"; + +/** + * Layout of the 5x5 test image: + * + * # = opaque (alpha = 1) + * . = transparent (alpha = 0) + * + * . . . . . + * . # . . . + * . . . . . + * . . . . . + * . # # # . + * + * The single isolated pixel is a 1-pixel artifact. + * The horizontal 3-pixel line is a 3-pixel connected region. + */ +function createIsolatedPixelImage(): AlphaChannelData { + const data = new Float32Array(25).fill(0); + data[1 * 5 + 1] = 1; + data[4 * 5 + 1] = 1; + data[4 * 5 + 2] = 1; + data[4 * 5 + 3] = 1; + return createAlphaChannelData({ width: 5, height: 5, data }); +} + +describe("applyNoiseRemoval", () => { + it("removes isolated pixels below the artifact size", () => { + const alpha = createIsolatedPixelImage(); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(result.data[1 * 5 + 1]).toBe(0); + }); + + it("preserves connected regions at or above the artifact size", () => { + const alpha = createIsolatedPixelImage(); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(result.data[4 * 5 + 1]).toBe(1); + expect(result.data[4 * 5 + 2]).toBe(1); + expect(result.data[4 * 5 + 3]).toBe(1); + }); + + it("removes a connected region smaller than the artifact size", () => { + const alpha = createIsolatedPixelImage(); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 4, connectivity: 4 }); + + expect(result.data[4 * 5 + 1]).toBe(0); + expect(result.data[4 * 5 + 2]).toBe(0); + expect(result.data[4 * 5 + 3]).toBe(0); + }); + + it("uses 4-connectivity by default", () => { + const alpha = createIsolatedPixelImage(); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 4 }); + + expect(result.data[4 * 5 + 1]).toBe(0); + }); + + it("treats diagonal pixels as disconnected with 4-connectivity", () => { + // Two diagonal pixels: (1,1) and (2,2) + const data = new Float32Array(9).fill(0); + data[1 * 3 + 1] = 1; + data[2 * 3 + 2] = 1; + const alpha = createAlphaChannelData({ width: 3, height: 3, data }); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(result.data[1 * 3 + 1]).toBe(0); + expect(result.data[2 * 3 + 2]).toBe(0); + }); + + it("treats diagonal pixels as connected with 8-connectivity", () => { + // Two diagonal pixels: (1,1) and (2,2) + const data = new Float32Array(9).fill(0); + data[1 * 3 + 1] = 1; + data[2 * 3 + 2] = 1; + const alpha = createAlphaChannelData({ width: 3, height: 3, data }); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 8 }); + + expect(result.data[1 * 3 + 1]).toBe(1); + expect(result.data[2 * 3 + 2]).toBe(1); + }); + + it("does not mutate the input alpha buffer", () => { + const alpha = createIsolatedPixelImage(); + const original = new Float32Array(alpha.data); + + applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(alpha.data).toEqual(original); + }); + + it("returns a new AlphaChannelData with the same dimensions", () => { + const alpha = createIsolatedPixelImage(); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(result.width).toBe(5); + expect(result.height).toBe(5); + expect(result.channels).toBe(1); + expect(result.format).toBe("alpha"); + expect(result.data).toBeInstanceOf(Float32Array); + expect(result.data.length).toBe(25); + }); + + it("throws when maxArtifactSize is not a positive integer", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyNoiseRemoval(alpha, { maxArtifactSize: 0, connectivity: 4 })).toThrow( + CleanupError, + ); + expect(() => applyNoiseRemoval(alpha, { maxArtifactSize: -1, connectivity: 4 })).toThrow( + CleanupError, + ); + expect(() => applyNoiseRemoval(alpha, { maxArtifactSize: 1.5, connectivity: 4 })).toThrow( + CleanupError, + ); + }); + + it("throws when connectivity is not 4 or 8", () => { + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + expect(() => applyNoiseRemoval(alpha, { maxArtifactSize: 1, connectivity: 6 as 4 })).toThrow( + CleanupError, + ); + }); + + it("preserves alpha values exactly 0 and non-0 as expected", () => { + const data = new Float32Array(4).fill(0); + data[0] = 0.001; + data[3] = 0.999; + const alpha = createAlphaChannelData({ width: 2, height: 2, data }); + + const result = applyNoiseRemoval(alpha, { maxArtifactSize: 2, connectivity: 4 }); + + expect(result.data[0]).toBe(0); + expect(result.data[1]).toBe(0); + expect(result.data[2]).toBe(0); + expect(result.data[3]).toBe(0); + }); +}); diff --git a/tests/color/color-converter.test.ts b/tests/color/color-converter.test.ts new file mode 100644 index 0000000..fba0553 --- /dev/null +++ b/tests/color/color-converter.test.ts @@ -0,0 +1,308 @@ +import { describe, expect, it } from "vitest"; +import { createImageData } from "../validation/test-image.js"; +import { createLinearImageData } from "./test-linear-image.js"; +import { createSeededRandom } from "../utils/random.js"; +import { linearToSrgb, srgbToLinear } from "../../src/index.js"; + +describe("srgbToLinear", () => { + it("converts pure black", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([0, 0, 0, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBe(0); + expect(linear.data[1]).toBe(0); + expect(linear.data[2]).toBe(0); + expect(linear.data[3]).toBe(1); + }); + + it("converts pure white", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([255, 255, 255, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBe(1); + expect(linear.data[1]).toBe(1); + expect(linear.data[2]).toBe(1); + expect(linear.data[3]).toBe(1); + }); + + it("converts red", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([255, 0, 0, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBe(1); + expect(linear.data[1]).toBe(0); + expect(linear.data[2]).toBe(0); + expect(linear.data[3]).toBe(1); + }); + + it("converts green", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([0, 255, 0, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBe(0); + expect(linear.data[1]).toBe(1); + expect(linear.data[2]).toBe(0); + expect(linear.data[3]).toBe(1); + }); + + it("converts blue", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([0, 0, 255, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBe(0); + expect(linear.data[1]).toBe(0); + expect(linear.data[2]).toBe(1); + expect(linear.data[3]).toBe(1); + }); + + it("converts middle gray", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([128, 128, 128, 255]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[0]).toBeCloseTo(0.21586, 5); + expect(linear.data[1]).toBeCloseTo(0.21586, 5); + expect(linear.data[2]).toBeCloseTo(0.21586, 5); + expect(linear.data[3]).toBe(1); + }); + + it("preserves alpha values", () => { + const image = createImageData({ + width: 1, + height: 1, + data: new Uint8Array([255, 255, 255, 128]), + }); + const linear = srgbToLinear(image); + + expect(linear.data[3]).toBeCloseTo(128 / 255, 7); + }); + + it("does not mutate the input image", () => { + const originalData = new Uint8Array([255, 0, 0, 255]); + const image = createImageData({ + width: 1, + height: 1, + data: originalData, + }); + const linear = srgbToLinear(image); + + expect(linear.data).not.toBe(originalData); + expect(image.data).toBe(originalData); + expect(image.data[0]).toBe(255); + }); + + it("throws for invalid format", () => { + const image = createImageData({ + width: 1, + height: 1, + format: "rgb8", + channels: 3, + data: new Uint8Array([255, 0, 0]), + }); + + expect(() => srgbToLinear(image)).toThrow(Error); + }); + + it("throws for invalid channel count", () => { + const image = createImageData({ + width: 1, + height: 1, + channels: 3, + data: new Uint8Array([255, 0, 0]), + }); + + expect(() => srgbToLinear(image)).toThrow(Error); + }); + + it("throws for mismatched buffer length", () => { + const image = createImageData({ + width: 2, + height: 2, + data: new Uint8Array([255, 0, 0, 255]), + }); + + expect(() => srgbToLinear(image)).toThrow(Error); + }); +}); + +describe("linearToSrgb", () => { + it("converts pure black", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const image = linearToSrgb(linear); + + expect(image.data[0]).toBe(0); + expect(image.data[1]).toBe(0); + expect(image.data[2]).toBe(0); + expect(image.data[3]).toBe(255); + }); + + it("converts pure white", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1, 1, 1, 1]), + }); + const image = linearToSrgb(linear); + + expect(image.data[0]).toBe(255); + expect(image.data[1]).toBe(255); + expect(image.data[2]).toBe(255); + expect(image.data[3]).toBe(255); + }); + + it("converts red", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1, 0, 0, 1]), + }); + const image = linearToSrgb(linear); + + expect(image.data[0]).toBe(255); + expect(image.data[1]).toBe(0); + expect(image.data[2]).toBe(0); + expect(image.data[3]).toBe(255); + }); + + it("converts middle gray", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0.21586, 0.21586, 0.21586, 1]), + }); + const image = linearToSrgb(linear); + + expect(image.data[0]).toBe(128); + expect(image.data[1]).toBe(128); + expect(image.data[2]).toBe(128); + expect(image.data[3]).toBe(255); + }); + + it("restores alpha values", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1, 1, 1, 128 / 255]), + }); + const image = linearToSrgb(linear); + + expect(image.data[3]).toBe(128); + }); + + it("does not mutate the input image", () => { + const originalData = new Float32Array([1, 0, 0, 1]); + const linear = createLinearImageData({ + width: 1, + height: 1, + data: originalData, + }); + const image = linearToSrgb(linear); + + expect(image.data).not.toBe(originalData); + expect(linear.data).toBe(originalData); + expect(linear.data[0]).toBe(1); + }); + + it("throws for invalid format", () => { + const linear = createLinearImageData({ + width: 1, + height: 1, + }); + // Force an invalid format for the error test. + const invalid = { ...linear, format: "rgba8" as const }; + + expect(() => linearToSrgb(invalid as unknown as typeof linear)).toThrow(Error); + }); + + it("throws for mismatched buffer length", () => { + const linear = createLinearImageData({ + width: 2, + height: 2, + data: new Float32Array([1, 0, 0, 1]), + }); + + expect(() => linearToSrgb(linear)).toThrow(Error); + }); +}); + +describe("sRGB round-trip", () => { + it("recovers an image with several colors", () => { + const width = 2; + const height = 2; + const pixels: [number, number, number, number][] = [ + [0, 0, 0, 255], + [255, 255, 255, 255], + [255, 0, 0, 128], + [128, 128, 128, 255], + ]; + const buffer = new Uint8Array(width * height * 4); + for (let i = 0; i < pixels.length; i++) { + const pixel = pixels[i]; + const offset = i * 4; + buffer[offset] = pixel[0]; + buffer[offset + 1] = pixel[1]; + buffer[offset + 2] = pixel[2]; + buffer[offset + 3] = pixel[3]; + } + + const image = createImageData({ width, height, data: buffer }); + const linear = srgbToLinear(image); + const recovered = linearToSrgb(linear); + + expect(recovered.data).toEqual(buffer); + expect(recovered.width).toBe(width); + expect(recovered.height).toBe(height); + expect(recovered.format).toBe("rgba8"); + }); + + it("recovers an image with pseudo-random colors", () => { + const width = 4; + const height = 4; + const random = createSeededRandom(12345); + const buffer = new Uint8Array(width * height * 4); + for (let i = 0; i < buffer.length; i += 4) { + buffer[i] = Math.round(random() * 255); + buffer[i + 1] = Math.round(random() * 255); + buffer[i + 2] = Math.round(random() * 255); + buffer[i + 3] = Math.round(random() * 255); + } + + const image = createImageData({ width, height, data: buffer }); + const linear = srgbToLinear(image); + const recovered = linearToSrgb(linear); + + expect(recovered.data).toEqual(buffer); + }); +}); + +it("exports the public color conversion API", () => { + expect(typeof srgbToLinear).toBe("function"); + expect(typeof linearToSrgb).toBe("function"); +}); diff --git a/tests/color/color-properties.test.ts b/tests/color/color-properties.test.ts new file mode 100644 index 0000000..029cea5 --- /dev/null +++ b/tests/color/color-properties.test.ts @@ -0,0 +1,171 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { linearToSrgb, srgbToLinear } from "../../src/color/color-converter.js"; +import { createImageData } from "../validation/test-image.js"; +import { createLinearImageData } from "./test-linear-image.js"; + +function imageDataArbitrary(): fc.Arbitrary<{ + width: number; + height: number; + data: Uint8Array; +}> { + return fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc.integer({ min: 1, max: 8 }).map((height) => ({ + width, + height, + })), + ) + .chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .array(fc.integer({ min: 0, max: 255 }), { + minLength: pixelCount * 4, + maxLength: pixelCount * 4, + }) + .map((values) => ({ + width, + height, + data: new Uint8Array(values), + })); + }); +} + +function linearImageDataArbitrary(): fc.Arbitrary<{ + width: number; + height: number; + data: Float32Array; +}> { + return fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc.integer({ min: 1, max: 8 }).map((height) => ({ + width, + height, + })), + ) + .chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 4, + maxLength: pixelCount * 4, + }) + .map((values) => ({ + width, + height, + data: new Float32Array(values), + })); + }); +} + +describe("color conversion property-based invariants", () => { + it("round-trips sRGB through linear and back exactly", () => { + fc.assert( + fc.property(imageDataArbitrary(), ({ width, height, data }) => { + const image = createImageData({ width, height, data }); + const linear = srgbToLinear(image); + const recovered = linearToSrgb(linear); + + expect(recovered.width).toBe(width); + expect(recovered.height).toBe(height); + expect(recovered.format).toBe("rgba8"); + expect(recovered.channels).toBe(4); + expect(recovered.data).toEqual(data); + }), + ); + }); + + it("srgbToLinear preserves dimensions and produces valid ranges", () => { + fc.assert( + fc.property(imageDataArbitrary(), ({ width, height, data }) => { + const image = createImageData({ width, height, data }); + const linear = srgbToLinear(image); + + expect(linear.width).toBe(width); + expect(linear.height).toBe(height); + expect(linear.channels).toBe(4); + expect(linear.format).toBe("linear-rgba8"); + expect(linear.data).toBeInstanceOf(Float32Array); + expect(linear.data.length).toBe(width * height * 4); + + for (const value of linear.data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + }), + ); + }); + + it("linearToSrgb preserves dimensions and produces valid ranges", () => { + fc.assert( + fc.property(linearImageDataArbitrary(), ({ width, height, data }) => { + const linear = createLinearImageData({ width, height, data }); + const image = linearToSrgb(linear); + + expect(image.width).toBe(width); + expect(image.height).toBe(height); + expect(image.channels).toBe(4); + expect(image.format).toBe("rgba8"); + expect(image.data).toBeInstanceOf(Uint8Array); + expect(image.data.length).toBe(width * height * 4); + + for (const value of image.data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(255); + } + }), + ); + }); + + it("srgbToLinear is deterministic", () => { + fc.assert( + fc.property(imageDataArbitrary(), ({ width, height, data }) => { + const image = createImageData({ width, height, data }); + const resultA = srgbToLinear(image); + const resultB = srgbToLinear(image); + + expect(resultA.data).toEqual(resultB.data); + }), + ); + }); + + it("linearToSrgb is deterministic", () => { + fc.assert( + fc.property(linearImageDataArbitrary(), ({ width, height, data }) => { + const linear = createLinearImageData({ width, height, data }); + const resultA = linearToSrgb(linear); + const resultB = linearToSrgb(linear); + + expect(resultA.data).toEqual(resultB.data); + }), + ); + }); + + it("does not mutate the input sRGB buffer", () => { + fc.assert( + fc.property(imageDataArbitrary(), ({ width, height, data }) => { + const original = new Uint8Array(data); + const image = createImageData({ width, height, data }); + srgbToLinear(image); + + expect(data).toEqual(original); + }), + ); + }); + + it("does not mutate the input linear buffer", () => { + fc.assert( + fc.property(linearImageDataArbitrary(), ({ width, height, data }) => { + const original = new Float32Array(data); + const linear = createLinearImageData({ width, height, data }); + linearToSrgb(linear); + + expect(data).toEqual(original); + }), + ); + }); +}); diff --git a/tests/color/srgb.test.ts b/tests/color/srgb.test.ts new file mode 100644 index 0000000..1f73f4b --- /dev/null +++ b/tests/color/srgb.test.ts @@ -0,0 +1,85 @@ +import { describe, expect, it } from "vitest"; +import { linearToSrgbChannel, srgbToLinearChannel } from "../../src/color/srgb.js"; + +const FLOAT_TOLERANCE = 1e-6; + +describe("srgbToLinearChannel", () => { + it("converts pure black to 0", () => { + expect(srgbToLinearChannel(0)).toBe(0); + }); + + it("converts pure white to 1", () => { + expect(srgbToLinearChannel(1)).toBe(1); + }); + + it("converts middle gray", () => { + expect(srgbToLinearChannel(128 / 255)).toBeCloseTo(0.21586, 5); + }); + + it("converts red", () => { + expect(srgbToLinearChannel(1)).toBe(1); + }); + + it("converts green", () => { + expect(srgbToLinearChannel(1)).toBe(1); + }); + + it("converts blue", () => { + expect(srgbToLinearChannel(1)).toBe(1); + }); + + it("uses the linear segment below the threshold", () => { + expect(srgbToLinearChannel(0.04045)).toBeCloseTo(0.0031308, 7); + }); + + it("uses the gamma segment above the threshold", () => { + expect(srgbToLinearChannel(0.04046)).toBeGreaterThan(srgbToLinearChannel(0.04045)); + }); +}); + +describe("linearToSrgbChannel", () => { + it("converts pure black to 0", () => { + expect(linearToSrgbChannel(0)).toBe(0); + }); + + it("converts pure white to 1", () => { + expect(linearToSrgbChannel(1)).toBeCloseTo(1, 10); + }); + + it("converts middle gray", () => { + expect(linearToSrgbChannel(0.21586)).toBeCloseTo(128 / 255, 5); + }); + + it("uses the linear segment below the threshold", () => { + expect(linearToSrgbChannel(0.0031308)).toBeCloseTo(0.04045, 5); + }); + + it("uses the gamma segment above the threshold", () => { + expect(linearToSrgbChannel(0.0031309)).toBeGreaterThan(linearToSrgbChannel(0.0031308)); + }); +}); + +describe("sRGB round-trip", () => { + it("recovers every 8-bit value exactly", () => { + for (let value = 0; value <= 255; value++) { + const normalized = value / 255; + const linear = srgbToLinearChannel(normalized); + const recovered = linearToSrgbChannel(linear); + const recoveredUint8 = Math.round(recovered * 255); + + expect(recoveredUint8).toBe(value); + expect(Math.abs(recovered - normalized)).toBeLessThan(FLOAT_TOLERANCE); + } + }); + + it("recovers several random colors within tolerance", () => { + const randomValues = [0.1, 0.25, 0.5, 0.75, 0.9, 0.123456, 0.987654]; + + for (const value of randomValues) { + const linear = srgbToLinearChannel(value); + const recovered = linearToSrgbChannel(linear); + + expect(Math.abs(recovered - value)).toBeLessThan(FLOAT_TOLERANCE); + } + }); +}); diff --git a/tests/color/test-linear-image.ts b/tests/color/test-linear-image.ts new file mode 100644 index 0000000..25a8dae --- /dev/null +++ b/tests/color/test-linear-image.ts @@ -0,0 +1,27 @@ +import type { LinearImageData } from "../../src/color/index.js"; + +/** + * Create a synthetic LinearImageData object for color tests. + */ +export function createLinearImageData(options: { + width: number; + height: number; + data?: Float32Array; + path?: string; +}): LinearImageData { + const width = options.width; + const height = options.height; + const channels = 4; + const expectedSize = Math.max(0, width * height * channels); + const data = options.data ?? new Float32Array(expectedSize); + const path = options.path ?? "/test/linear.png"; + + return { + width, + height, + channels, + format: "linear-rgba8", + data, + path, + }; +} diff --git a/tests/export/png-export-integration.test.ts b/tests/export/png-export-integration.test.ts new file mode 100644 index 0000000..2514d5f --- /dev/null +++ b/tests/export/png-export-integration.test.ts @@ -0,0 +1,146 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { reconstructAlpha, reconstructForeground } from "../../src/reconstruction/index.js"; +import { cleanup } from "../../src/cleanup/index.js"; +import { exportPng } from "../../src/export/index.js"; +import { loadImage } from "../../src/io/index.js"; +import { linearToSrgb } from "../../src/color/index.js"; +import type { LinearImageData } from "../../src/color/index.js"; +import { + composeObservation, + constantAlpha, + constantForeground, +} from "../reconstruction/helpers.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +describe("exportPng integration with reconstruction pipeline", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdir(join(tmpdir(), "alphaforge-export-integration-"), { + recursive: true, + }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("exports a reconstructed foreground and alpha channel", async () => { + const foregroundColor: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const alphaValue = 0.5; + const width = 4; + const height = 4; + + const observation1 = composeObservation({ + width, + height, + foreground: constantForeground(foregroundColor), + alpha: constantAlpha(alphaValue), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground: constantForeground(foregroundColor), + alpha: constantAlpha(alphaValue), + background: BLACK_BACKGROUND, + }); + + const alpha = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + const foreground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha, + }); + + const path = join(tempDir, "reconstructed.png"); + const result = await exportPng({ foreground, alpha, path }); + + expect(result.path).toBe(path); + expect(result.bytes).toBeGreaterThan(0); + + const image = await loadImage(path); + expect(image.width).toBe(width); + expect(image.height).toBe(height); + expect(image.channels).toBe(4); + + const linearImage: LinearImageData = { + width: 1, + height: 1, + channels: 4, + format: "linear-rgba8", + data: new Float32Array([...foregroundColor, alphaValue]), + path, + }; + const expected = linearToSrgb(linearImage); + + expect(image.data[0]).toBe(expected.data[0]); + expect(image.data[1]).toBe(expected.data[1]); + expect(image.data[2]).toBe(expected.data[2]); + expect(image.data[3]).toBe(128); + }); + + it("chains cleanup -> exportPng deterministically", async () => { + const foregroundColor: readonly [number, number, number] = [0.3, 0.5, 0.7]; + const alphaValue = 0.8; + const width = 3; + const height = 3; + + const observation1 = composeObservation({ + width, + height, + foreground: constantForeground(foregroundColor), + alpha: constantAlpha(alphaValue), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground: constantForeground(foregroundColor), + alpha: constantAlpha(alphaValue), + background: BLACK_BACKGROUND, + }); + + const alpha = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + const foreground = reconstructForeground({ + inputs: [ + { observation: observation1, background: WHITE_BACKGROUND }, + { observation: observation2, background: BLACK_BACKGROUND }, + ], + alpha, + }); + const cleaned = cleanup({ + alpha, + foreground, + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }); + + const path = join(tempDir, "cleaned.png"); + const result = await exportPng({ + foreground: cleaned.foreground!, + alpha: cleaned.alpha, + path, + }); + + expect(result.path).toBe(path); + expect(result.bytes).toBeGreaterThan(0); + + const image = await loadImage(path); + expect(image.width).toBe(width); + expect(image.height).toBe(height); + expect(image.channels).toBe(4); + }); +}); diff --git a/tests/export/png-export-properties.test.ts b/tests/export/png-export-properties.test.ts new file mode 100644 index 0000000..9889d72 --- /dev/null +++ b/tests/export/png-export-properties.test.ts @@ -0,0 +1,243 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import * as fc from "fast-check"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { randomUUID } from "node:crypto"; +import { exportPng } from "../../src/export/index.js"; +import { loadImage } from "../../src/io/index.js"; +import { createAlphaChannelData, createForegroundImageData } from "../cleanup/helpers.js"; + +describe("exportPng property-based invariants", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdir(join(tmpdir(), "alphaforge-export-properties-"), { + recursive: true, + }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + function dimensionsArbitrary() { + return fc + .integer({ min: 1, max: 4 }) + .chain((width) => fc.integer({ min: 1, max: 4 }).map((height) => ({ width, height }))); + } + + it("determinism: identical inputs produce equivalent outputs", async () => { + await fc.assert( + fc.asyncProperty( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 3, + maxLength: pixelCount * 3, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregroundValues, alphaValues]) => ({ + width, + height, + foreground: createForegroundImageData({ + width, + height, + data: new Float32Array(foregroundValues), + }), + alpha: createAlphaChannelData({ + width, + height, + data: new Float32Array(alphaValues), + }), + })); + }), + async ({ width, height, foreground, alpha }) => { + const pathA = join(tempDir, `${randomUUID()}.png`); + const pathB = join(tempDir, `${randomUUID()}.png`); + await exportPng({ foreground, alpha, path: pathA }); + await exportPng({ foreground, alpha, path: pathB }); + const imageA = await loadImage(pathA); + const imageB = await loadImage(pathB); + expect(imageA.width).toBe(width); + expect(imageA.height).toBe(height); + expect(imageA.data).toEqual(imageB.data); + }, + ), + ); + }); + + it("does not mutate the input buffers", async () => { + await fc.assert( + fc.asyncProperty( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 3, + maxLength: pixelCount * 3, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregroundValues, alphaValues]) => ({ + foreground: createForegroundImageData({ + width, + height, + data: new Float32Array(foregroundValues), + }), + alpha: createAlphaChannelData({ + width, + height, + data: new Float32Array(alphaValues), + }), + })); + }), + async ({ foreground, alpha }) => { + const path = join(tempDir, `${randomUUID()}.png`); + const originalForeground = new Float32Array(foreground.data); + const originalAlpha = new Float32Array(alpha.data); + await exportPng({ foreground, alpha, path }); + expect(foreground.data).toEqual(originalForeground); + expect(alpha.data).toEqual(originalAlpha); + }, + ), + ); + }); + + it("preserves output dimensions", async () => { + await fc.assert( + fc.asyncProperty( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 3, + maxLength: pixelCount * 3, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregroundValues, alphaValues]) => ({ + width, + height, + foreground: createForegroundImageData({ + width, + height, + data: new Float32Array(foregroundValues), + }), + alpha: createAlphaChannelData({ + width, + height, + data: new Float32Array(alphaValues), + }), + })); + }), + async ({ width, height, foreground, alpha }) => { + const path = join(tempDir, `${randomUUID()}.png`); + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + expect(image.width).toBe(width); + expect(image.height).toBe(height); + }, + ), + ); + }); + + it("produces valid RGBA8 pixel values for valid inputs", async () => { + await fc.assert( + fc.asyncProperty( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 3, + maxLength: pixelCount * 3, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregroundValues, alphaValues]) => ({ + foreground: createForegroundImageData({ + width, + height, + data: new Float32Array(foregroundValues), + }), + alpha: createAlphaChannelData({ + width, + height, + data: new Float32Array(alphaValues), + }), + })); + }), + async ({ foreground, alpha }) => { + const path = join(tempDir, `${randomUUID()}.png`); + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + for (const value of image.data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(255); + } + }, + ), + ); + }); + + it("does not propagate NaN or Infinity from valid inputs", async () => { + await fc.assert( + fc.asyncProperty( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount * 3, + maxLength: pixelCount * 3, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregroundValues, alphaValues]) => ({ + foreground: createForegroundImageData({ + width, + height, + data: new Float32Array(foregroundValues), + }), + alpha: createAlphaChannelData({ + width, + height, + data: new Float32Array(alphaValues), + }), + })); + }), + async ({ foreground, alpha }) => { + const path = join(tempDir, `${randomUUID()}.png`); + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + for (const value of image.data) { + expect(Number.isNaN(value)).toBe(false); + expect(Number.isFinite(value)).toBe(true); + } + }, + ), + ); + }); +}); diff --git a/tests/export/png-export.test.ts b/tests/export/png-export.test.ts new file mode 100644 index 0000000..c8afe8a --- /dev/null +++ b/tests/export/png-export.test.ts @@ -0,0 +1,296 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { exportPng, ExportError } from "../../src/export/index.js"; +import { loadImage } from "../../src/io/index.js"; +import { linearToSrgb } from "../../src/color/index.js"; +import type { LinearImageData } from "../../src/color/index.js"; +import { createAlphaChannelData, createForegroundImageData } from "../cleanup/helpers.js"; + +describe("exportPng", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdir(join(tmpdir(), "alphaforge-export-test-"), { + recursive: true, + }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("creates a valid PNG file", async () => { + const path = join(tempDir, "valid.png"); + const foreground = createForegroundImageData({ + width: 2, + height: 2, + data: new Float32Array([0.5, 0.5, 0.5, 0.25, 0.25, 0.25, 0.75, 0.75, 0.75, 1, 1, 1]), + }); + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0, 0.5, 0.25, 1]), + }); + + const result = await exportPng({ foreground, alpha, path }); + + expect(result.path).toBe(path); + expect(result.bytes).toBeGreaterThan(0); + const image = await loadImage(path); + expect(image.width).toBe(2); + expect(image.height).toBe(2); + expect(image.channels).toBe(4); + }); + + it("preserves image dimensions", async () => { + const path = join(tempDir, "dimensions.png"); + const foreground = createForegroundImageData({ width: 3, height: 4 }); + const alpha = createAlphaChannelData({ width: 3, height: 4 }); + + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + + expect(image.width).toBe(3); + expect(image.height).toBe(4); + }); + + it("preserves alpha values", async () => { + const path = join(tempDir, "alpha.png"); + const alphaValues = new Float32Array([0, 64 / 255, 128 / 255, 1]); + const foreground = createForegroundImageData({ width: 2, height: 2 }); + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: alphaValues, + }); + + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + + expect(image.data[3]).toBe(0); + expect(image.data[7]).toBe(64); + expect(image.data[11]).toBe(128); + expect(image.data[15]).toBe(255); + }); + + it("converts linear RGB through the existing linearToSrgb logic", async () => { + const path = join(tempDir, "color.png"); + const red = 0.4; + const green = 0.6; + const blue = 0.8; + const foreground = createForegroundImageData({ + width: 1, + height: 1, + data: new Float32Array([red, green, blue]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + const linearImage: LinearImageData = { + width: 1, + height: 1, + channels: 4, + format: "linear-rgba8", + data: new Float32Array([red, green, blue, 1]), + path, + }; + const expected = linearToSrgb(linearImage); + + await exportPng({ foreground, alpha, path }); + const image = await loadImage(path); + + expect(image.data[0]).toBe(expected.data[0]); + expect(image.data[1]).toBe(expected.data[1]); + expect(image.data[2]).toBe(expected.data[2]); + expect(image.data[3]).toBe(255); + }); + + it("does not mutate the input buffers", async () => { + const path = join(tempDir, "immutable.png"); + const foreground = createForegroundImageData({ + width: 2, + height: 2, + data: new Float32Array([0.1, 0.2, 0.3, 0.4, 0.5, 0.6, 0.7, 0.8, 0.9, 1.0, 0.95, 0.85]), + }); + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array([0.1, 0.5, 0.9, 0.0]), + }); + const originalForeground = new Float32Array(foreground.data); + const originalAlpha = new Float32Array(alpha.data); + + await exportPng({ foreground, alpha, path }); + + expect(foreground.data).toEqual(originalForeground); + expect(alpha.data).toEqual(originalAlpha); + }); + + it("throws when the foreground and alpha dimensions do not match", async () => { + const foreground = createForegroundImageData({ width: 2, height: 2 }); + const alpha = createAlphaChannelData({ width: 3, height: 2 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "mismatch.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the foreground channel count is invalid", async () => { + const foreground = { + width: 1, + height: 1, + channels: 4, + format: "linear-rgb" as const, + data: new Float32Array([0, 0, 0, 0]), + }; + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "fg-channels.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the alpha channel count is invalid", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = { + width: 1, + height: 1, + channels: 3, + format: "alpha" as const, + data: new Float32Array([0, 0, 0]), + }; + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "alpha-channels.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the foreground format is invalid", async () => { + const foreground = { + width: 1, + height: 1, + channels: 3, + format: "linear-rgba8" as const, + data: new Float32Array([0, 0, 0]), + }; + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "fg-format.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the alpha format is invalid", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = { + width: 1, + height: 1, + channels: 1, + format: "linear-rgb" as const, + data: new Float32Array([0.5]), + }; + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "alpha-format.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the foreground contains NaN", async () => { + const foreground = createForegroundImageData({ + width: 1, + height: 1, + data: new Float32Array([Number.NaN, 0, 0]), + }); + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "fg-nan.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the foreground contains Infinity", async () => { + const foreground = createForegroundImageData({ + width: 1, + height: 1, + data: new Float32Array([Number.POSITIVE_INFINITY, 0, 0]), + }); + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "fg-inf.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the foreground contains values outside [0, 1]", async () => { + const foreground = createForegroundImageData({ + width: 1, + height: 1, + data: new Float32Array([1.5, 0, 0]), + }); + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "fg-range.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the alpha contains NaN", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([Number.NaN]), + }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "alpha-nan.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the alpha contains Infinity", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([Number.POSITIVE_INFINITY]), + }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "alpha-inf.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the alpha contains values outside [0, 1]", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([-0.1]), + }); + + await expect( + exportPng({ foreground, alpha, path: join(tempDir, "alpha-range.png") }), + ).rejects.toThrow(ExportError); + }); + + it("throws when the path is empty", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect(exportPng({ foreground, alpha, path: "" })).rejects.toThrow(ExportError); + }); + + it("throws when the path is not a string", async () => { + const foreground = createForegroundImageData({ width: 1, height: 1 }); + const alpha = createAlphaChannelData({ width: 1, height: 1 }); + + await expect(exportPng({ foreground, alpha, path: 123 as unknown as string })).rejects.toThrow( + ExportError, + ); + }); +}); diff --git a/tests/index.test.ts b/tests/index.test.ts new file mode 100644 index 0000000..eeb587d --- /dev/null +++ b/tests/index.test.ts @@ -0,0 +1,104 @@ +import { describe, expect, it } from "vitest"; +import { + VERSION, + srgbToLinear, + linearToSrgb, + reconstructAlpha, + AlphaReconstructionError, + reconstructForeground, + ForegroundReconstructionError, + cleanup, + CleanupError, + exportPng, + ExportError, + measureBackgroundColor, + validateBackgroundColors, + assertBackgroundColorsValid, + BackgroundMismatchError, + DEFAULT_BACKGROUND_BORDER_WIDTH, + DEFAULT_BACKGROUND_MISMATCH_THRESHOLD, + reconstructPipeline, + PipelineError, +} from "../src/index.js"; +import type { + ReconstructPipelineOptions, + ReconstructPipelineResult, + ReconstructPipelineCleanupOptions, +} from "../src/index.js"; + +describe("alphaforge", () => { + it("exports a version constant", () => { + expect(VERSION).toBe("0.9.0"); + }); + + it("exports color conversion functions", () => { + expect(typeof srgbToLinear).toBe("function"); + expect(typeof linearToSrgb).toBe("function"); + }); + + it("exports alpha reconstruction", () => { + expect(typeof reconstructAlpha).toBe("function"); + expect(typeof AlphaReconstructionError).toBe("function"); + }); + + it("exports foreground reconstruction", () => { + expect(typeof reconstructForeground).toBe("function"); + expect(typeof ForegroundReconstructionError).toBe("function"); + }); + + it("exports cleanup", () => { + expect(typeof cleanup).toBe("function"); + expect(typeof CleanupError).toBe("function"); + }); + + it("exports png export", () => { + expect(typeof exportPng).toBe("function"); + expect(typeof ExportError).toBe("function"); + }); + + it("exports background validation", () => { + expect(typeof measureBackgroundColor).toBe("function"); + expect(typeof validateBackgroundColors).toBe("function"); + expect(typeof assertBackgroundColorsValid).toBe("function"); + expect(typeof BackgroundMismatchError).toBe("function"); + expect(typeof DEFAULT_BACKGROUND_BORDER_WIDTH).toBe("number"); + expect(typeof DEFAULT_BACKGROUND_MISMATCH_THRESHOLD).toBe("number"); + }); + + it("exports pipeline", () => { + expect(typeof reconstructPipeline).toBe("function"); + expect(typeof PipelineError).toBe("function"); + }); + + it("exports pipeline type surface", () => { + const cleanupOptions: ReconstructPipelineCleanupOptions = { + threshold: { alphaLow: 0, alphaHigh: 1 }, + }; + const options: ReconstructPipelineOptions = { + observationAPath: "a.png", + observationBPath: "b.png", + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + cleanup: cleanupOptions, + }; + const result: ReconstructPipelineResult = { + alpha: { + width: 1, + height: 1, + channels: 1, + format: "alpha", + data: new Float32Array([1]), + }, + foreground: { + width: 1, + height: 1, + channels: 3, + format: "linear-rgb", + data: new Float32Array([0, 0, 0]), + }, + }; + + expect(options.cleanup).toBe(cleanupOptions); + expect(result.alpha.data[0]).toBe(1); + }); +}); diff --git a/tests/io/image-loader.test.ts b/tests/io/image-loader.test.ts new file mode 100644 index 0000000..bb0dbd5 --- /dev/null +++ b/tests/io/image-loader.test.ts @@ -0,0 +1,72 @@ +import { mkdir, rm, writeFile } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import { afterAll, beforeAll, describe, expect, it } from "vitest"; +import sharp from "sharp"; +import { loadImage, ImageLoadError } from "../../src/io/index.js"; + +describe("loadImage", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdir(join(tmpdir(), "alphaforge-io-test-"), { + recursive: true, + }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("loads an RGBA PNG and normalizes it to RGBA8", async () => { + const path = join(tempDir, "rgba.png"); + const width = 2; + const height = 2; + const pixels = Buffer.from([255, 0, 0, 255, 0, 255, 0, 128, 0, 0, 255, 64, 255, 255, 255, 0]); + + await sharp(pixels, { raw: { width, height, channels: 4 } }) + .png() + .toFile(path); + + const image = await loadImage(path); + + expect(image.width).toBe(width); + expect(image.height).toBe(height); + expect(image.channels).toBe(4); + expect(image.format).toBe("rgba8"); + expect(image.path).toBe(path); + expect(image.data).toEqual(new Uint8Array(pixels)); + }); + + it("adds an opaque alpha channel when loading an RGB PNG", async () => { + const path = join(tempDir, "rgb.png"); + const width = 1; + const height = 1; + const pixels = Buffer.from([128, 64, 32]); + + await sharp(pixels, { raw: { width, height, channels: 3 } }) + .png() + .toFile(path); + + const image = await loadImage(path); + + expect(image.width).toBe(width); + expect(image.height).toBe(height); + expect(image.channels).toBe(4); + expect(image.format).toBe("rgba8"); + expect(image.data).toEqual(new Uint8Array([128, 64, 32, 255])); + }); + + it("throws ImageLoadError when the file does not exist", async () => { + const path = join(tempDir, "missing.png"); + + await expect(loadImage(path)).rejects.toThrow(ImageLoadError); + }); + + it("throws ImageLoadError when the file is not a valid image", async () => { + const path = join(tempDir, "invalid.png"); + await writeFile(path, "not a valid image", "utf8"); + + await expect(loadImage(path)).rejects.toThrow(ImageLoadError); + }); +}); diff --git a/tests/pipeline/reconstruct-pipeline.test.ts b/tests/pipeline/reconstruct-pipeline.test.ts new file mode 100644 index 0000000..14fcfbd --- /dev/null +++ b/tests/pipeline/reconstruct-pipeline.test.ts @@ -0,0 +1,468 @@ +import { describe, expect, it, beforeAll, afterAll } from "vitest"; +import { mkdir, rm } from "node:fs/promises"; +import { tmpdir } from "node:os"; +import { join } from "node:path"; +import sharp from "sharp"; +import { reconstructPipeline, PipelineError } from "../../src/pipeline/index.js"; +import { srgbToLinear } from "../../src/color/index.js"; +import { loadImage } from "../../src/io/index.js"; +import { exportPng } from "../../src/export/index.js"; +import { BackgroundMismatchError } from "../../src/validation/index.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function buildReferenceRgba(width: number, height: number): Uint8Array { + const data = new Uint8Array(width * height * 4); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + const centerX = width / 2; + const centerY = height / 2; + const distance = Math.sqrt((x - centerX) ** 2 + (y - centerY) ** 2); + const maxDistance = Math.sqrt(centerX ** 2 + centerY ** 2); + const alpha = Math.max(0, 1 - distance / maxDistance); + + data[index] = 220; + data[index + 1] = 40; + data[index + 2] = 60; + data[index + 3] = Math.round(alpha * 255); + } + } + + return data; +} + +/** + * Build a reference with a transparent border and an opaque interior. + * + * Used for tests that enable background validation, because the validation + * samples the image border and expects it to match the declared background. + */ +function buildReferenceRgbaWithClearBorder( + width: number, + height: number, + borderWidth: number, +): Uint8Array { + const data = new Uint8Array(width * height * 4); + + for (let y = 0; y < height; y += 1) { + for (let x = 0; x < width; x += 1) { + const index = (y * width + x) * 4; + const onBorder = + x < borderWidth || x >= width - borderWidth || y < borderWidth || y >= height - borderWidth; + + data[index] = 220; + data[index + 1] = 40; + data[index + 2] = 60; + data[index + 3] = onBorder ? 0 : 255; + } + } + + return data; +} + +function composeObservation( + referenceRgba: Uint8Array, + width: number, + height: number, + backgroundSrgb: readonly [number, number, number], +) { + const referenceImage = { + width, + height, + channels: 4, + format: "rgba8" as const, + data: referenceRgba, + path: "", + }; + const referenceLinear = srgbToLinear(referenceImage); + + const backgroundLinear = srgbToLinear({ + width: 1, + height: 1, + channels: 4, + format: "rgba8" as const, + data: new Uint8Array([backgroundSrgb[0], backgroundSrgb[1], backgroundSrgb[2], 255]), + path: "", + }); + + const linearData = new Float32Array(width * height * 4); + + for (let i = 0; i < width * height; i += 1) { + const alpha = referenceLinear.data[i * 4 + 3]; + const red = referenceLinear.data[i * 4]; + const green = referenceLinear.data[i * 4 + 1]; + const blue = referenceLinear.data[i * 4 + 2]; + const oneMinusAlpha = 1 - alpha; + + linearData[i * 4] = alpha * red + oneMinusAlpha * backgroundLinear.data[0]; + linearData[i * 4 + 1] = alpha * green + oneMinusAlpha * backgroundLinear.data[1]; + linearData[i * 4 + 2] = alpha * blue + oneMinusAlpha * backgroundLinear.data[2]; + linearData[i * 4 + 3] = 1; + } + + return { + width, + height, + channels: 4, + format: "linear-rgba8" as const, + data: linearData, + path: "", + }; +} + +async function writePng(imageData: { + width: number; + height: number; + data: Float32Array; + path: string; +}): Promise { + const outputData = new Uint8Array(imageData.width * imageData.height * 4); + const maxChannel = imageData.width * imageData.height * 4; + + for (let i = 0; i < maxChannel; i += 1) { + const value = imageData.data[i]; + outputData[i] = Math.max(0, Math.min(255, Math.round(value * 255))); + } + + const path = imageData.path; + await sharp(outputData, { + raw: { width: imageData.width, height: imageData.height, channels: 4 }, + }) + .png() + .toFile(path); + return path; +} + +describe("reconstructPipeline", () => { + let tempDir: string; + + beforeAll(async () => { + tempDir = await mkdir(join(tmpdir(), "alphaforge-pipeline-"), { recursive: true }); + }); + + afterAll(async () => { + await rm(tempDir, { recursive: true, force: true }); + }); + + it("runs the full pipeline and returns alpha and foreground", async () => { + const width = 16; + const height = 16; + const referenceRgba = buildReferenceRgba(width, height); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ ...whiteObservation, path: join(tempDir, "white.png") }); + const blackPath = await writePng({ ...blackObservation, path: join(tempDir, "black.png") }); + + const result = await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }); + + expect(result.alpha.width).toBe(width); + expect(result.alpha.height).toBe(height); + expect(result.alpha.channels).toBe(1); + expect(result.alpha.format).toBe("alpha"); + + expect(result.foreground.width).toBe(width); + expect(result.foreground.height).toBe(height); + expect(result.foreground.channels).toBe(3); + expect(result.foreground.format).toBe("linear-rgb"); + }); + + it("applies cleanup when configured", async () => { + const width = 8; + const height = 8; + const referenceRgba = buildReferenceRgba(width, height); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "cleanup-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "cleanup-black.png"), + }); + + const result = await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + cleanup: { + threshold: { alphaLow: 0.05, alphaHigh: 0.95 }, + }, + }); + + expect(result.alpha.width).toBe(width); + expect(result.alpha.height).toBe(height); + expect(result.foreground.width).toBe(width); + expect(result.foreground.height).toBe(height); + }); + + it("produces deterministic results for identical inputs", async () => { + const width = 8; + const height = 8; + const referenceRgba = buildReferenceRgba(width, height); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "deterministic-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "deterministic-black.png"), + }); + + const options = { + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }; + + const resultA = await reconstructPipeline(options); + const resultB = await reconstructPipeline(options); + + expect(resultA.alpha.data).toEqual(resultB.alpha.data); + expect(resultA.foreground.data).toEqual(resultB.foreground.data); + }); + + it("throws PipelineError when an observation file is missing", async () => { + await expect( + reconstructPipeline({ + observationAPath: join(tempDir, "missing.png"), + observationBPath: join(tempDir, "missing.png"), + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }), + ).rejects.toThrow(PipelineError); + }); + + it("throws PipelineError when observations have mismatched dimensions", async () => { + const smallRgba = buildReferenceRgba(8, 8); + const largeRgba = buildReferenceRgba(16, 16); + + const smallWhite = composeObservation(smallRgba, 8, 8, [255, 255, 255]); + const largeBlack = composeObservation(largeRgba, 16, 16, [0, 0, 0]); + + const smallPath = await writePng({ ...smallWhite, path: join(tempDir, "small.png") }); + const largePath = await writePng({ ...largeBlack, path: join(tempDir, "large.png") }); + + await expect( + reconstructPipeline({ + observationAPath: smallPath, + observationBPath: largePath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }), + ).rejects.toThrow(PipelineError); + }); + + it("does not mutate the loaded image data", async () => { + const width = 8; + const height = 8; + const referenceRgba = buildReferenceRgba(width, height); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "immutable-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "immutable-black.png"), + }); + + const imageA = await loadImage(whitePath); + const imageB = await loadImage(blackPath); + const originalA = new Uint8Array(imageA.data); + const originalB = new Uint8Array(imageB.data); + + await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }); + + const loadedA = await loadImage(whitePath); + const loadedB = await loadImage(blackPath); + + expect(loadedA.data).toEqual(originalA); + expect(loadedB.data).toEqual(originalB); + }); + + it("is invariant to swapping observations and backgrounds", async () => { + const width = 16; + const height = 16; + const referenceRgba = buildReferenceRgba(width, height); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "swap-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "swap-black.png"), + }); + + const resultA = await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }); + + const resultB = await reconstructPipeline({ + observationAPath: blackPath, + observationBPath: whitePath, + backgroundA: BLACK_BACKGROUND, + backgroundB: WHITE_BACKGROUND, + }); + + expect(resultA.alpha.data).toEqual(resultB.alpha.data); + expect(resultA.foreground.data).toEqual(resultB.foreground.data); + + const exportPathA = join(tempDir, "swap-result-a.png"); + const exportPathB = join(tempDir, "swap-result-b.png"); + + await exportPng({ foreground: resultA.foreground, alpha: resultA.alpha, path: exportPathA }); + await exportPng({ foreground: resultB.foreground, alpha: resultB.alpha, path: exportPathB }); + + const exportedA = await loadImage(exportPathA); + const exportedB = await loadImage(exportPathB); + + expect(exportedA.data).toEqual(exportedB.data); + }); + + it("succeeds when background validation is enabled and colors match", async () => { + const width = 32; + const height = 32; + const borderWidth = 4; + const referenceRgba = buildReferenceRgbaWithClearBorder(width, height, borderWidth); + + const whiteObservation = composeObservation(referenceRgba, width, height, [255, 255, 255]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "bg-valid-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "bg-valid-black.png"), + }); + + const result = await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + backgroundValidation: { + borderWidth, + }, + }); + + expect(result.alpha.width).toBe(width); + expect(result.alpha.height).toBe(height); + expect(result.foreground.width).toBe(width); + expect(result.foreground.height).toBe(height); + }); + + it("throws BackgroundMismatchError when background validation detects a mismatch", async () => { + const width = 32; + const height = 32; + const borderWidth = 4; + const referenceRgba = buildReferenceRgbaWithClearBorder(width, height, borderWidth); + + const whiteObservation = composeObservation(referenceRgba, width, height, [128, 128, 128]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "bg-mismatch-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "bg-mismatch-black.png"), + }); + + await expect( + reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + backgroundValidation: { + borderWidth, + threshold: 0.01, + }, + }), + ).rejects.toThrow(PipelineError); + + try { + await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + backgroundValidation: { + borderWidth, + threshold: 0.01, + }, + }); + expect.fail("Expected pipeline to throw"); + } catch (error) { + expect(error).toBeInstanceOf(PipelineError); + const pipelineError = error as PipelineError; + expect(pipelineError.cause).toBeInstanceOf(BackgroundMismatchError); + } + }); + + it("does not run background validation when the option is omitted", async () => { + const width = 32; + const height = 32; + const borderWidth = 4; + const referenceRgba = buildReferenceRgbaWithClearBorder(width, height, borderWidth); + + const whiteObservation = composeObservation(referenceRgba, width, height, [128, 128, 128]); + const blackObservation = composeObservation(referenceRgba, width, height, [0, 0, 0]); + + const whitePath = await writePng({ + ...whiteObservation, + path: join(tempDir, "bg-no-validation-white.png"), + }); + const blackPath = await writePng({ + ...blackObservation, + path: join(tempDir, "bg-no-validation-black.png"), + }); + + const result = await reconstructPipeline({ + observationAPath: whitePath, + observationBPath: blackPath, + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }); + + expect(result.alpha.width).toBe(width); + expect(result.alpha.height).toBe(height); + }); +}); diff --git a/tests/reconstruction/alpha-reconstruction-properties.test.ts b/tests/reconstruction/alpha-reconstruction-properties.test.ts new file mode 100644 index 0000000..5fd7681 --- /dev/null +++ b/tests/reconstruction/alpha-reconstruction-properties.test.ts @@ -0,0 +1,332 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { reconstructAlpha } from "../../src/reconstruction/index.js"; +import { composeObservation, constantAlpha, constantForeground } from "./helpers.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function linearColorArbitrary(): fc.Arbitrary { + return fc.tuple( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + ); +} + +function dimensionsArbitrary(): fc.Arbitrary<{ width: number; height: number }> { + return fc + .integer({ min: 1, max: 8 }) + .chain((width) => fc.integer({ min: 1, max: 8 }).map((height) => ({ width, height }))); +} + +describe("alpha reconstruction property-based invariants", () => { + it("is deterministic for identical inputs", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + const options = { + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }; + const resultA = reconstructAlpha(options); + const resultB = reconstructAlpha(options); + + expect(resultA.data).toEqual(resultB.data); + }, + ), + ); + }); + + it("is invariant to observation order", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + + const resultA = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + const resultB = reconstructAlpha({ + input1: { observation: observation2, background: BLACK_BACKGROUND }, + input2: { observation: observation1, background: WHITE_BACKGROUND }, + }); + + expect(resultA.data).toEqual(resultB.data); + }, + ), + ); + }); + + it("preserves alpha channel dimensions and buffer size", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + const result = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + + expect(result.width).toBe(width); + expect(result.height).toBe(height); + expect(result.channels).toBe(1); + expect(result.format).toBe("alpha"); + expect(result.data).toBeInstanceOf(Float32Array); + expect(result.data.length).toBe(width * height); + }, + ), + ); + }); + + it("produces alpha values in [0, 1]", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + const result = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + + for (const value of result.data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + }, + ), + ); + }); + + it("does not mutate the input observations", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 8 }).chain((width) => + fc.integer({ min: 1, max: 8 }).chain((height) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ), + ({ width, height, foreground, alpha }) => { + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + const original1 = new Float32Array(observation1.data); + const original2 = new Float32Array(observation2.data); + + reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + + expect(observation1.data).toEqual(original1); + expect(observation2.data).toEqual(original2); + }, + ), + ); + }); + + it("recovers constant alpha values approximately", () => { + fc.assert( + fc.property( + fc.float({ min: 0, max: 1, noNaN: true }).chain((alphaValue) => + fc.integer({ min: 1, max: 8 }).chain((width) => + fc.integer({ min: 1, max: 8 }).map((height) => ({ + alphaValue, + width, + height, + })), + ), + ), + ({ alphaValue, width, height }) => { + const foreground = constantForeground([0.4, 0.6, 0.8]); + const alpha = constantAlpha(alphaValue); + const observation1 = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + const result = reconstructAlpha({ + input1: { observation: observation1, background: WHITE_BACKGROUND }, + input2: { observation: observation2, background: BLACK_BACKGROUND }, + }); + + for (const value of result.data) { + expect(value).toBeCloseTo(alphaValue, 5); + } + }, + ), + ); + }); +}); diff --git a/tests/reconstruction/alpha-reconstruction.test.ts b/tests/reconstruction/alpha-reconstruction.test.ts new file mode 100644 index 0000000..de25b26 --- /dev/null +++ b/tests/reconstruction/alpha-reconstruction.test.ts @@ -0,0 +1,613 @@ +import { describe, expect, it } from "vitest"; +import { createLinearImageData } from "../color/test-linear-image.js"; +import type { LinearImageData } from "../../src/color/index.js"; +import { reconstructAlpha, AlphaReconstructionError } from "../../src/reconstruction/index.js"; +import type { ReconstructAlphaOptions } from "../../src/reconstruction/index.js"; +import { + composeObservation, + constantAlpha, + constantForeground, + createSeededRandom, +} from "./helpers.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function makeOptions( + observation1: LinearImageData, + background1: readonly [number, number, number], + observation2: LinearImageData, + background2: readonly [number, number, number], +): ReconstructAlphaOptions { + return { + input1: { observation: observation1, background: background1 }, + input2: { observation: observation2, background: background2 }, + }; +} + +describe("reconstructAlpha", () => { + it("recovers fully opaque pixels as alpha = 1", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + const result = reconstructAlpha( + makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND), + ); + + expect(result.data[0]).toBe(1); + }); + + it("recovers fully transparent pixels as alpha = 0", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(0), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(0), + background: BLACK_BACKGROUND, + }); + + const result = reconstructAlpha( + makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND), + ); + + expect(result.data[0]).toBe(0); + }); + + it("recovers semi-transparent pixels", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const background1: readonly [number, number, number] = [1, 0, 0.2]; + const background2: readonly [number, number, number] = [0, 0.2, 1]; + const alpha = 0.5; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data[0]).toBeCloseTo(alpha, 6); + }); + + it("recovers alpha for arbitrary foreground colors", () => { + const width = 2; + const height = 2; + const foregrounds: readonly [number, number, number][] = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0.5, 0.5, 0.5], + ]; + const alpha = 0.6; + + const observation1 = composeObservation({ + width, + height, + foreground: (pixel) => foregrounds[pixel], + alpha: constantAlpha(alpha), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width, + height, + foreground: (pixel) => foregrounds[pixel], + alpha: constantAlpha(alpha), + background: BLACK_BACKGROUND, + }); + + const result = reconstructAlpha( + makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND), + ); + + for (let i = 0; i < foregrounds.length; i++) { + expect(result.data[i]).toBeCloseTo(alpha, 6); + } + }); + + it("recovers alpha for arbitrary background colors", () => { + const foreground: readonly [number, number, number] = [0.1, 0.9, 0.5]; + const background1: readonly [number, number, number] = [0.2, 0.3, 0.4]; + const background2: readonly [number, number, number] = [0.8, 0.7, 0.1]; + const alpha = 0.75; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data[0]).toBeCloseTo(alpha, 6); + }); + + it("recovers alpha for configurable non-extreme backgrounds", () => { + const foreground: readonly [number, number, number] = [0.3, 0.6, 0.9]; + const background1: readonly [number, number, number] = [1, 1, 1]; + const background2: readonly [number, number, number] = [0.04, 0.04, 0.04]; + const alpha = 0.35; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data[0]).toBeCloseTo(alpha, 6); + }); + + it("excludes channels whose background denominator is at or below EPSILON", () => { + const foreground: readonly [number, number, number] = [0.2, 0.3, 0.7]; + const background1: readonly [number, number, number] = [0.5, 0.5, 0.5]; + const background2: readonly [number, number, number] = [0.5 + 0.5e-6, 0.5 + 0.5e-6, 0.2]; + const alpha = 0.3; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data[0]).toBeCloseTo(alpha, 6); + }); + + it("excludes channels whose background denominator is zero", () => { + const foreground: readonly [number, number, number] = [0.7, 0.2, 0.5]; + const background1: readonly [number, number, number] = [0.5, 0.2, 0.8]; + const background2: readonly [number, number, number] = [0.5, 0.8, 0.2]; + const alpha = 0.55; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data[0]).toBeCloseTo(alpha, 6); + }); + + it("throws when all background channels differ by at most EPSILON", () => { + const foreground: readonly [number, number, number] = [0.2, 0.3, 0.7]; + const background1: readonly [number, number, number] = [0.1, 0.2, 0.3]; + const background2: readonly [number, number, number] = [ + 0.1 + 0.5e-6, + 0.2 + 0.5e-6, + 0.3 + 0.5e-6, + ]; + + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(0.5), + background: background1, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(0.5), + background: background2, + }); + + expect(() => + reconstructAlpha(makeOptions(observation1, background1, observation2, background2)), + ).toThrow(AlphaReconstructionError); + }); + + it("clamps reconstructed alpha to [0, 1]", () => { + // Physically inconsistent observations that would produce alpha > 1. + const highCase: ReconstructAlphaOptions = { + input1: { + observation: createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }), + background: [1, 0, 0], + }, + input2: { + observation: createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0.5, 0, 0, 1]), + }), + background: [0, 0, 0], + }, + }; + + // Physically inconsistent observations that would produce alpha < 0. + const lowCase: ReconstructAlphaOptions = { + input1: { + observation: createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }), + background: [0, 0, 0], + }, + input2: { + observation: createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1, 0, 0, 1]), + }), + background: [0.5, 0, 0], + }, + }; + + expect(reconstructAlpha(highCase).data[0]).toBe(1); + expect(reconstructAlpha(lowCase).data[0]).toBe(0); + }); + + it("produces deterministic output for identical inputs", () => { + const foreground: readonly [number, number, number] = [0.3, 0.6, 0.9]; + const background1: readonly [number, number, number] = [0.9, 0.1, 0.4]; + const background2: readonly [number, number, number] = [0.1, 0.8, 0.7]; + const alpha = 0.4; + + const observation1 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background1, + }); + const observation2 = composeObservation({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alpha), + background: background2, + }); + + const options = makeOptions(observation1, background1, observation2, background2); + const resultA = reconstructAlpha(options); + const resultB = reconstructAlpha(options); + + expect(resultA.data).toEqual(resultB.data); + expect(resultA.data).toBeInstanceOf(Float32Array); + expect(resultA.format).toBe("alpha"); + expect(resultA.channels).toBe(1); + }); + + it("throws when observations have different dimensions", () => { + const observation1 = composeObservation({ + width: 2, + height: 2, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 3, + height: 2, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + expect(() => + reconstructAlpha(makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when an observation is not in linear-rgba8 format", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + const malformed = { + ...observation1, + format: "rgba8" as const, + } as unknown as LinearImageData; + + expect(() => + reconstructAlpha(makeOptions(malformed, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when an observation has the wrong number of channels", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + const malformed = { + ...observation1, + channels: 3, + data: new Float32Array(3), + } as unknown as LinearImageData; + + expect(() => + reconstructAlpha(makeOptions(malformed, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when an observation buffer size does not match dimensions", () => { + const observation1 = createLinearImageData({ + width: 2, + height: 2, + data: new Float32Array(4), + }); + const observation2 = composeObservation({ + width: 2, + height: 2, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + expect(() => + reconstructAlpha(makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when observation values are outside [0, 1]", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + observation1.data[0] = -0.1; + + expect(() => + reconstructAlpha(makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when observation values are not finite", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + observation1.data[0] = Number.NaN; + + expect(() => + reconstructAlpha(makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when background values are outside [0, 1]", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + expect(() => + reconstructAlpha(makeOptions(observation1, [-0.1, 0, 0], observation2, BLACK_BACKGROUND)), + ).toThrow(AlphaReconstructionError); + }); + + it("throws when background values are not finite", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: BLACK_BACKGROUND, + }); + + expect(() => + reconstructAlpha( + makeOptions(observation1, [Number.NaN, 0, 0], observation2, BLACK_BACKGROUND), + ), + ).toThrow(AlphaReconstructionError); + }); + + it("does not mutate the input observations", () => { + const observation1 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([0.2, 0.4, 0.6]), + alpha: constantAlpha(0.5), + background: WHITE_BACKGROUND, + }); + const observation2 = composeObservation({ + width: 1, + height: 1, + foreground: constantForeground([0.2, 0.4, 0.6]), + alpha: constantAlpha(0.5), + background: BLACK_BACKGROUND, + }); + + const original1 = new Float32Array(observation1.data); + const original2 = new Float32Array(observation2.data); + + reconstructAlpha(makeOptions(observation1, WHITE_BACKGROUND, observation2, BLACK_BACKGROUND)); + + expect(observation1.data).toEqual(original1); + expect(observation2.data).toEqual(original2); + }); + + it("matches generated reference alpha over thousands of randomized pixels", () => { + const width = 100; + const height = 100; + const pixelCount = width * height; + const random = createSeededRandom(12345); + const background1: readonly [number, number, number] = [1, 1, 1]; + const background2: readonly [number, number, number] = [0, 0, 0]; + + const foregrounds = new Float32Array(pixelCount * 3); + const alphas = new Float32Array(pixelCount); + + for (let i = 0; i < pixelCount; i++) { + foregrounds[i * 3] = random(); + foregrounds[i * 3 + 1] = random(); + foregrounds[i * 3 + 2] = random(); + alphas[i] = random(); + } + + const observation1 = composeObservation({ + width, + height, + foreground: (pixel) => [ + foregrounds[pixel * 3], + foregrounds[pixel * 3 + 1], + foregrounds[pixel * 3 + 2], + ], + alpha: (pixel) => alphas[pixel], + background: background1, + }); + const observation2 = composeObservation({ + width, + height, + foreground: (pixel) => [ + foregrounds[pixel * 3], + foregrounds[pixel * 3 + 1], + foregrounds[pixel * 3 + 2], + ], + alpha: (pixel) => alphas[pixel], + background: background2, + }); + + const result = reconstructAlpha( + makeOptions(observation1, background1, observation2, background2), + ); + + expect(result.data).toHaveLength(pixelCount); + for (let i = 0; i < pixelCount; i++) { + expect(result.data[i]).toBeCloseTo(alphas[i], 5); + } + }); +}); diff --git a/tests/reconstruction/foreground-reconstruction-properties.test.ts b/tests/reconstruction/foreground-reconstruction-properties.test.ts new file mode 100644 index 0000000..2e3085e --- /dev/null +++ b/tests/reconstruction/foreground-reconstruction-properties.test.ts @@ -0,0 +1,297 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { reconstructForeground } from "../../src/reconstruction/index.js"; +import { composeObservation, constantAlpha, constantForeground } from "./helpers.js"; +import { createAlphaChannelData } from "../cleanup/helpers.js"; +import type { LinearImageData } from "../../src/color/index.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function linearColorArbitrary(): fc.Arbitrary { + return fc.tuple( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + ); +} + +function dimensionsArbitrary(): fc.Arbitrary<{ width: number; height: number }> { + return fc + .integer({ min: 1, max: 8 }) + .chain((width) => fc.integer({ min: 1, max: 8 }).map((height) => ({ width, height }))); +} + +function makeDualOptions(options: { + width: number; + height: number; + foreground: (pixel: number) => readonly [number, number, number]; + alpha: (pixel: number) => number; + alphaData?: Float32Array; +}): { + inputs: readonly [ + { + readonly observation: LinearImageData; + readonly background: readonly [number, number, number]; + }, + { + readonly observation: LinearImageData; + readonly background: readonly [number, number, number]; + }, + ]; + alpha: ReturnType; +} { + const { width, height, foreground, alpha } = options; + const observationA = composeObservation({ + width, + height, + foreground, + alpha, + background: WHITE_BACKGROUND, + }); + const observationB = composeObservation({ + width, + height, + foreground, + alpha, + background: BLACK_BACKGROUND, + }); + + const pixelCount = width * height; + const alphaData = + options.alphaData ?? new Float32Array(Array.from({ length: pixelCount }, (_, i) => alpha(i))); + + return { + inputs: [ + { observation: observationA, background: WHITE_BACKGROUND }, + { observation: observationB, background: BLACK_BACKGROUND }, + ], + alpha: createAlphaChannelData({ width, height, data: alphaData }), + }; +} + +describe("foreground reconstruction property-based invariants", () => { + it("is deterministic for identical inputs", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const options = makeDualOptions({ width, height, foreground, alpha }); + const resultA = reconstructForeground(options); + const resultB = reconstructForeground(options); + + expect(resultA.data).toEqual(resultB.data); + }, + ), + ); + }); + + it("is invariant to observation order", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const options = makeDualOptions({ width, height, foreground, alpha }); + const resultA = reconstructForeground(options); + const resultB = reconstructForeground({ + inputs: [options.inputs[1], options.inputs[0]], + alpha: options.alpha, + }); + + expect(resultA.data).toEqual(resultB.data); + }, + ), + ); + }); + + it("preserves foreground image dimensions and buffer size", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const options = makeDualOptions({ width, height, foreground, alpha }); + const result = reconstructForeground(options); + + expect(result.width).toBe(width); + expect(result.height).toBe(height); + expect(result.channels).toBe(3); + expect(result.format).toBe("linear-rgb"); + expect(result.data).toBeInstanceOf(Float32Array); + expect(result.data.length).toBe(width * height * 3); + }, + ), + ); + }); + + it("produces foreground color values in [0, 1]", () => { + fc.assert( + fc.property( + dimensionsArbitrary().chain(({ width, height }) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ({ width, height, foreground, alpha }) => { + const options = makeDualOptions({ width, height, foreground, alpha }); + const result = reconstructForeground(options); + + for (const value of result.data) { + expect(Number.isFinite(value)).toBe(true); + expect(value).toBeGreaterThanOrEqual(0); + expect(value).toBeLessThanOrEqual(1); + } + }, + ), + ); + }); + + it("does not mutate the input observations or alpha channel", () => { + fc.assert( + fc.property( + fc.integer({ min: 1, max: 8 }).chain((width) => + fc.integer({ min: 1, max: 8 }).chain((height) => { + const pixelCount = width * height; + return fc + .tuple( + fc.array(linearColorArbitrary(), { + minLength: pixelCount, + maxLength: pixelCount, + }), + fc.array(fc.float({ min: 0, max: 1, noNaN: true }), { + minLength: pixelCount, + maxLength: pixelCount, + }), + ) + .map(([foregrounds, alphas]) => ({ + width, + height, + foreground: (pixel: number) => foregrounds[pixel], + alpha: (pixel: number) => alphas[pixel], + })); + }), + ), + ({ width, height, foreground, alpha }) => { + const options = makeDualOptions({ width, height, foreground, alpha }); + const originalObservationA = new Float32Array(options.inputs[0].observation.data); + const originalObservationB = new Float32Array(options.inputs[1].observation.data); + const originalAlpha = new Float32Array(options.alpha.data); + + reconstructForeground(options); + + expect(options.inputs[0].observation.data).toEqual(originalObservationA); + expect(options.inputs[1].observation.data).toEqual(originalObservationB); + expect(options.alpha.data).toEqual(originalAlpha); + }, + ), + ); + }); + + it("recovers constant foreground colors approximately", () => { + fc.assert( + fc.property( + fc.float({ min: Math.fround(0.02), max: 1, noNaN: true }).chain((alphaValue) => + linearColorArbitrary().chain((foregroundColor) => + fc.integer({ min: 1, max: 8 }).chain((width) => + fc.integer({ min: 1, max: 8 }).map((height) => ({ + alphaValue, + foregroundColor, + width, + height, + })), + ), + ), + ), + ({ alphaValue, foregroundColor, width, height }) => { + const foreground = constantForeground(foregroundColor); + const alpha = constantAlpha(alphaValue); + const options = makeDualOptions({ + width, + height, + foreground, + alpha, + alphaData: new Float32Array(width * height).fill(alphaValue), + }); + const result = reconstructForeground(options); + + for (let i = 0; i < width * height; i += 1) { + expect(result.data[i * 3]).toBeCloseTo(foregroundColor[0], 5); + expect(result.data[i * 3 + 1]).toBeCloseTo(foregroundColor[1], 5); + expect(result.data[i * 3 + 2]).toBeCloseTo(foregroundColor[2], 5); + } + }, + ), + ); + }); +}); diff --git a/tests/reconstruction/foreground-reconstruction.test.ts b/tests/reconstruction/foreground-reconstruction.test.ts new file mode 100644 index 0000000..65f35a4 --- /dev/null +++ b/tests/reconstruction/foreground-reconstruction.test.ts @@ -0,0 +1,666 @@ +import { describe, expect, it } from "vitest"; +import { createLinearImageData } from "../color/test-linear-image.js"; +import type { LinearImageData } from "../../src/color/index.js"; +import type { AlphaChannelData } from "../../src/reconstruction/index.js"; +import { + reconstructForeground, + ForegroundReconstructionError, +} from "../../src/reconstruction/index.js"; +import { composeObservation, constantAlpha, constantForeground } from "./helpers.js"; + +const WHITE_BACKGROUND: readonly [number, number, number] = [1, 1, 1]; +const BLACK_BACKGROUND: readonly [number, number, number] = [0, 0, 0]; + +function createAlphaChannelData(options: { + width: number; + height: number; + data?: Float32Array; +}): AlphaChannelData { + const { width, height } = options; + const pixelCount = width * height; + const data = options.data ?? new Float32Array(pixelCount); + + if (data.length !== pixelCount) { + throw new Error("Alpha data length does not match dimensions"); + } + + return { + width, + height, + channels: 1, + format: "alpha", + data, + }; +} + +function makeDualOptions(options: { + width: number; + height: number; + foreground: (pixel: number) => readonly [number, number, number]; + alpha: (pixel: number) => number; + backgroundA: readonly [number, number, number]; + backgroundB: readonly [number, number, number]; + alphaData?: Float32Array; +}): { + inputs: readonly [ + { + readonly observation: LinearImageData; + readonly background: readonly [number, number, number]; + }, + { + readonly observation: LinearImageData; + readonly background: readonly [number, number, number]; + }, + ]; + alpha: AlphaChannelData; +} { + const { width, height, foreground, alpha, backgroundA, backgroundB } = options; + const observationA = composeObservation({ + width, + height, + foreground, + alpha, + background: backgroundA, + }); + const observationB = composeObservation({ + width, + height, + foreground, + alpha, + background: backgroundB, + }); + + const pixelCount = width * height; + const alphaData = + options.alphaData ?? new Float32Array(Array.from({ length: pixelCount }, (_, i) => alpha(i))); + + return { + inputs: [ + { observation: observationA, background: backgroundA }, + { observation: observationB, background: backgroundB }, + ], + alpha: createAlphaChannelData({ width, height, data: alphaData }), + }; +} + +describe("reconstructForeground", () => { + it("recovers fully opaque pixels as the original foreground color", () => { + const foreground: readonly [number, number, number] = [0.8, 0.3, 0.6]; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(1), + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }), + ); + + expect(result.data[0]).toBeCloseTo(foreground[0], 6); + expect(result.data[1]).toBeCloseTo(foreground[1], 6); + expect(result.data[2]).toBeCloseTo(foreground[2], 6); + }); + + it("returns black fallback for fully transparent pixels", () => { + const foreground: readonly [number, number, number] = [0.8, 0.3, 0.6]; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(0), + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }), + ); + + expect(result.data[0]).toBe(0); + expect(result.data[1]).toBe(0); + expect(result.data[2]).toBe(0); + }); + + it("recovers semi-transparent pixels", () => { + const foreground: readonly [number, number, number] = [0.4, 0.6, 0.8]; + const backgroundA: readonly [number, number, number] = [1, 0, 0.2]; + const alphaValue = 0.5; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB: BLACK_BACKGROUND, + }), + ); + + expect(result.data[0]).toBeCloseTo(foreground[0], 6); + expect(result.data[1]).toBeCloseTo(foreground[1], 6); + expect(result.data[2]).toBeCloseTo(foreground[2], 6); + }); + + it("recovers arbitrary foreground colors", () => { + const width = 2; + const height = 2; + const foregrounds: readonly [number, number, number][] = [ + [1, 0, 0], + [0, 1, 0], + [0, 0, 1], + [0.5, 0.5, 0.5], + ]; + const alphaValue = 0.6; + + const result = reconstructForeground( + makeDualOptions({ + width, + height, + foreground: (pixel) => foregrounds[pixel], + alpha: constantAlpha(alphaValue), + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }), + ); + + for (let i = 0; i < foregrounds.length; i++) { + expect(result.data[i * 3]).toBeCloseTo(foregrounds[i][0], 6); + expect(result.data[i * 3 + 1]).toBeCloseTo(foregrounds[i][1], 6); + expect(result.data[i * 3 + 2]).toBeCloseTo(foregrounds[i][2], 6); + } + }); + + it("recovers foreground against arbitrary background colors", () => { + const foreground: readonly [number, number, number] = [0.1, 0.9, 0.5]; + const backgroundA: readonly [number, number, number] = [0.2, 0.3, 0.4]; + const backgroundB: readonly [number, number, number] = [0.9, 0.1, 0.7]; + const alphaValue = 0.75; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB, + }), + ); + + expect(result.data[0]).toBeCloseTo(foreground[0], 6); + expect(result.data[1]).toBeCloseTo(foreground[1], 6); + expect(result.data[2]).toBeCloseTo(foreground[2], 6); + }); + + it("is invariant to observation order", () => { + const foreground: readonly [number, number, number] = [0.3, 0.6, 0.9]; + const backgroundA: readonly [number, number, number] = [1, 0.1, 0.2]; + const backgroundB: readonly [number, number, number] = [0.1, 0.2, 1]; + const alphaValue = 0.4; + + const options = makeDualOptions({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB, + }); + + const resultA = reconstructForeground(options); + const resultB = reconstructForeground({ + inputs: [options.inputs[1], options.inputs[0]], + alpha: options.alpha, + }); + + expect(resultA.data).toEqual(resultB.data); + }); + + it("uses black fallback for alpha below ALPHA_THRESHOLD", () => { + const foreground: readonly [number, number, number] = [0.2, 0.4, 0.6]; + const backgroundA: readonly [number, number, number] = [0.5, 0.5, 0.5]; + const alphaValue = 0.005; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB: BLACK_BACKGROUND, + }), + ); + + expect(result.data[0]).toBe(0); + expect(result.data[1]).toBe(0); + expect(result.data[2]).toBe(0); + }); + + it("applies the recovery equation for alpha just above ALPHA_THRESHOLD", () => { + const foreground: readonly [number, number, number] = [0.2, 0.4, 0.6]; + const backgroundA: readonly [number, number, number] = [0.5, 0.5, 0.5]; + const alphaValue = 0.02; + + const result = reconstructForeground( + makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB: BLACK_BACKGROUND, + }), + ); + + expect(result.data[0]).toBeCloseTo(foreground[0], 5); + expect(result.data[1]).toBeCloseTo(foreground[1], 5); + expect(result.data[2]).toBeCloseTo(foreground[2], 5); + }); + + it("clamps recovered foreground values to [0, 1]", () => { + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([0.5]), + }); + + const highObservation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1, 1, 1, 1]), + }); + const lowObservation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + + const highResult = reconstructForeground({ + inputs: [ + { observation: highObservation, background: BLACK_BACKGROUND }, + { observation: highObservation, background: BLACK_BACKGROUND }, + ], + alpha, + }); + const lowResult = reconstructForeground({ + inputs: [ + { observation: lowObservation, background: WHITE_BACKGROUND }, + { observation: lowObservation, background: WHITE_BACKGROUND }, + ], + alpha, + }); + + expect(highResult.data[0]).toBe(1); + expect(highResult.data[1]).toBe(1); + expect(highResult.data[2]).toBe(1); + + expect(lowResult.data[0]).toBe(0); + expect(lowResult.data[1]).toBe(0); + expect(lowResult.data[2]).toBe(0); + }); + + it("produces deterministic output for identical inputs", () => { + const foreground: readonly [number, number, number] = [0.3, 0.6, 0.9]; + const backgroundA: readonly [number, number, number] = [0.1, 0.8, 0.7]; + const alphaValue = 0.4; + + const options = makeDualOptions({ + width: 4, + height: 4, + foreground: constantForeground(foreground), + alpha: constantAlpha(alphaValue), + backgroundA, + backgroundB: BLACK_BACKGROUND, + }); + + const resultA = reconstructForeground(options); + const resultB = reconstructForeground(options); + + expect(resultA.data).toEqual(resultB.data); + expect(resultA.data).toBeInstanceOf(Float32Array); + expect(resultA.format).toBe("linear-rgb"); + expect(resultA.channels).toBe(3); + }); + + it("throws when observations and alpha dimensions do not match", () => { + const observation = composeObservation({ + width: 2, + height: 2, + foreground: constantForeground([1, 0, 0]), + alpha: constantAlpha(1), + background: WHITE_BACKGROUND, + }); + const alpha = createAlphaChannelData({ + width: 3, + height: 2, + data: new Float32Array(6), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when an observation is not in linear-rgba8 format", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + const malformed = { + ...observation, + format: "rgba8" as const, + } as unknown as LinearImageData; + + expect(() => + reconstructForeground({ + inputs: [ + { observation: malformed, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when an observation has the wrong number of channels", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + const malformed = { + ...observation, + channels: 3, + data: new Float32Array(3), + } as unknown as LinearImageData; + + expect(() => + reconstructForeground({ + inputs: [ + { observation: malformed, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when an observation buffer size does not match dimensions", () => { + const observation = createLinearImageData({ + width: 2, + height: 2, + data: new Float32Array(4), + }); + const alpha = createAlphaChannelData({ + width: 2, + height: 2, + data: new Float32Array(4), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when alpha format is not alpha", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = { + width: 1, + height: 1, + channels: 1, + format: "linear-rgba8" as const, + data: new Float32Array([1]), + } as unknown as AlphaChannelData; + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when alpha channel count is not 1", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = { + width: 1, + height: 1, + channels: 3, + format: "alpha" as const, + data: new Float32Array([0, 0, 0]), + } as unknown as AlphaChannelData; + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when alpha buffer size does not match dimensions", () => { + const observation = createLinearImageData({ + width: 2, + height: 2, + data: new Float32Array(16), + }); + const alpha = { + width: 2, + height: 2, + channels: 1, + format: "alpha" as const, + data: new Float32Array(3), + } as AlphaChannelData; + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when alpha values are outside [0, 1]", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1.5]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when alpha values are not finite", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([Number.NaN]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when observation values are outside [0, 1]", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([1.5, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when observation values are not finite", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([Number.NaN, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: WHITE_BACKGROUND }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when background values are outside [0, 1]", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: [-0.1, 0, 0] }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("throws when background values are not finite", () => { + const observation = createLinearImageData({ + width: 1, + height: 1, + data: new Float32Array([0, 0, 0, 1]), + }); + const alpha = createAlphaChannelData({ + width: 1, + height: 1, + data: new Float32Array([1]), + }); + + expect(() => + reconstructForeground({ + inputs: [ + { observation, background: [Number.NaN, 0, 0] }, + { observation, background: BLACK_BACKGROUND }, + ], + alpha, + }), + ).toThrow(ForegroundReconstructionError); + }); + + it("does not mutate the input observations or alpha", () => { + const options = makeDualOptions({ + width: 1, + height: 1, + foreground: constantForeground([0.2, 0.4, 0.6]), + alpha: constantAlpha(0.5), + backgroundA: WHITE_BACKGROUND, + backgroundB: BLACK_BACKGROUND, + }); + + const originalObservationA = new Float32Array(options.inputs[0].observation.data); + const originalObservationB = new Float32Array(options.inputs[1].observation.data); + const originalAlpha = new Float32Array(options.alpha.data); + + reconstructForeground(options); + + expect(options.inputs[0].observation.data).toEqual(originalObservationA); + expect(options.inputs[1].observation.data).toEqual(originalObservationB); + expect(options.alpha.data).toEqual(originalAlpha); + }); +}); diff --git a/tests/reconstruction/helpers.ts b/tests/reconstruction/helpers.ts new file mode 100644 index 0000000..654407a --- /dev/null +++ b/tests/reconstruction/helpers.ts @@ -0,0 +1,57 @@ +import { createLinearImageData } from "../color/test-linear-image.js"; +import type { LinearImageData } from "../../src/color/index.js"; +export { createSeededRandom } from "../utils/random.js"; + +/** + * Compose a single linear RGB observation from a foreground, per-pixel alpha, + * and a uniform background. + * + * Implements the Porter-Duff "over" operator for each pixel: + * + * C = α * F + (1 - α) * B + * + * The returned image has four channels (linear RGBA). The alpha channel is + * set to 1.0 because the alpha reconstruction algorithm reads only the RGB + * channels. + */ +export function composeObservation(options: { + width: number; + height: number; + foreground: (pixel: number) => readonly [number, number, number]; + alpha: (pixel: number) => number; + background: readonly [number, number, number]; +}): LinearImageData { + const { width, height, foreground, alpha, background } = options; + const pixelCount = width * height; + const data = new Float32Array(pixelCount * 4); + + for (let pixel = 0; pixel < pixelCount; pixel++) { + const alphaValue = alpha(pixel); + const [red, green, blue] = foreground(pixel); + const oneMinusAlpha = 1 - alphaValue; + const offset = pixel * 4; + + data[offset] = alphaValue * red + oneMinusAlpha * background[0]; + data[offset + 1] = alphaValue * green + oneMinusAlpha * background[1]; + data[offset + 2] = alphaValue * blue + oneMinusAlpha * background[2]; + data[offset + 3] = 1; + } + + return createLinearImageData({ width, height, data }); +} + +/** + * Create a foreground function that returns the same color for every pixel. + */ +export function constantForeground( + color: readonly [number, number, number], +): () => readonly [number, number, number] { + return () => color; +} + +/** + * Create an alpha function that returns the same value for every pixel. + */ +export function constantAlpha(value: number): () => number { + return () => value; +} diff --git a/tests/utils/random.ts b/tests/utils/random.ts new file mode 100644 index 0000000..4f0c87b --- /dev/null +++ b/tests/utils/random.ts @@ -0,0 +1,16 @@ +/** + * Create a deterministic linear congruential generator. + * + * The returned function produces values in [0, 1). The generator is fully + * deterministic for a given seed, which makes randomized regression tests + * reproducible without depending on Math.random. + */ +export function createSeededRandom(seed: number): () => number { + // 32-bit integer state. Classic Numerical Recipes LCG parameters. + let state = seed >>> 0; + + return () => { + state = (state * 1664525 + 1013904223) >>> 0; + return state / 0x100000000; + }; +} diff --git a/tests/validation/background-errors.test.ts b/tests/validation/background-errors.test.ts new file mode 100644 index 0000000..fac2bf0 --- /dev/null +++ b/tests/validation/background-errors.test.ts @@ -0,0 +1,55 @@ +import { describe, expect, it } from "vitest"; +import { BackgroundMismatchError } from "../../src/validation/background-errors.js"; +import { ValidationError } from "../../src/validation/validation-errors.js"; + +describe("BackgroundMismatchError", () => { + it("extends ValidationError", () => { + const error = new BackgroundMismatchError("test"); + expect(error).toBeInstanceOf(ValidationError); + }); + + it("sets the error name", () => { + const error = new BackgroundMismatchError("test"); + expect(error.name).toBe("BackgroundMismatchError"); + }); + + it("preserves the message", () => { + const error = new BackgroundMismatchError("declared mismatch"); + expect(error.message).toBe("declared mismatch"); + }); + + it("stores the optional cause", () => { + const cause = new Error("underlying issue"); + const error = new BackgroundMismatchError("test", { cause }); + expect(error.cause).toBe(cause); + }); + + it("stores the optional context", () => { + const context = { + backgroundA: [1, 1, 1] as const, + backgroundB: [0, 0, 0] as const, + measuredA: { + mean: [1, 1, 1] as const, + variance: [0, 0, 0] as const, + sampleCount: 100, + min: [1, 1, 1] as const, + max: [1, 1, 1] as const, + }, + measuredB: { + mean: [0, 0, 0] as const, + variance: [0, 0, 0] as const, + sampleCount: 100, + min: [0, 0, 0] as const, + max: [0, 0, 0] as const, + }, + distanceA: 0, + distanceB: 0, + threshold: 0.05, + pathA: "/a.png", + pathB: "/b.png", + }; + + const error = new BackgroundMismatchError("test", { context }); + expect(error.context).toEqual(context); + }); +}); diff --git a/tests/validation/background-validation-properties.test.ts b/tests/validation/background-validation-properties.test.ts new file mode 100644 index 0000000..81d4372 --- /dev/null +++ b/tests/validation/background-validation-properties.test.ts @@ -0,0 +1,186 @@ +import { describe, expect, it } from "vitest"; +import * as fc from "fast-check"; +import { + measureBackgroundColor, + validateBackgroundColors, +} from "../../src/validation/background-validation.js"; +import { createImageData } from "./test-image.js"; + +function createImageArbitrary( + width: number, + height: number, +): fc.Arbitrary> { + return fc + .array(fc.integer({ min: 0, max: 255 }), { + minLength: width * height * 4, + maxLength: width * height * 4, + }) + .map((values) => { + const data = new Uint8Array(values); + for (let i = 0; i < data.length; i += 4) { + data[i + 3] = 255; + } + return createImageData({ width, height, data }); + }); +} + +function smallImageArbitrary(): fc.Arbitrary> { + return fc + .integer({ min: 1, max: 8 }) + .chain((width) => + fc.integer({ min: 1, max: 8 }).chain((height) => createImageArbitrary(width, height)), + ); +} + +function linearColorArbitrary(): fc.Arbitrary { + return fc.tuple( + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + fc.float({ min: 0, max: 1, noNaN: true }), + ); +} + +describe("background validation property-based invariants", () => { + it("is deterministic for identical inputs", () => { + fc.assert( + fc.property( + smallImageArbitrary(), + smallImageArbitrary(), + linearColorArbitrary(), + linearColorArbitrary(), + (imageA, imageB, backgroundA, backgroundB) => { + const options = { + imageA, + imageB, + backgroundA, + backgroundB, + }; + const resultA = validateBackgroundColors(options); + const resultB = validateBackgroundColors(options); + + expect(resultA.isValid).toBe(resultB.isValid); + expect(resultA.distanceA).toBe(resultB.distanceA); + expect(resultA.distanceB).toBe(resultB.distanceB); + expect(resultA.measuredA.sampleCount).toBe(resultB.measuredA.sampleCount); + expect(resultA.measuredB.sampleCount).toBe(resultB.measuredB.sampleCount); + }, + ), + ); + }); + + it("does not mutate the input images", () => { + fc.assert( + fc.property( + smallImageArbitrary(), + smallImageArbitrary(), + linearColorArbitrary(), + linearColorArbitrary(), + (imageA, imageB, backgroundA, backgroundB) => { + const originalA = new Uint8Array(imageA.data); + const originalB = new Uint8Array(imageB.data); + + validateBackgroundColors({ + imageA, + imageB, + backgroundA, + backgroundB, + }); + + expect(imageA.data).toEqual(originalA); + expect(imageB.data).toEqual(originalB); + }, + ), + ); + }); + + it("produces measured colors in the valid linear RGB range", () => { + fc.assert( + fc.property(smallImageArbitrary(), (image) => { + const measured = measureBackgroundColor(image, 1); + + expect(measured.mean[0]).toBeGreaterThanOrEqual(0); + expect(measured.mean[0]).toBeLessThanOrEqual(1); + expect(measured.mean[1]).toBeGreaterThanOrEqual(0); + expect(measured.mean[1]).toBeLessThanOrEqual(1); + expect(measured.mean[2]).toBeGreaterThanOrEqual(0); + expect(measured.mean[2]).toBeLessThanOrEqual(1); + + expect(measured.min[0]).toBeGreaterThanOrEqual(0); + expect(measured.min[0]).toBeLessThanOrEqual(1); + expect(measured.max[0]).toBeGreaterThanOrEqual(0); + expect(measured.max[0]).toBeLessThanOrEqual(1); + }), + ); + }); + + it("reports a non-negative sample count", () => { + fc.assert( + fc.property(smallImageArbitrary(), (image) => { + const measured = measureBackgroundColor(image, 1); + expect(measured.sampleCount).toBeGreaterThanOrEqual(0); + }), + ); + }); + + it("produces distances in the valid range [0, sqrt(3)]", () => { + fc.assert( + fc.property( + smallImageArbitrary(), + smallImageArbitrary(), + linearColorArbitrary(), + linearColorArbitrary(), + (imageA, imageB, backgroundA, backgroundB) => { + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA, + backgroundB, + }); + + expect(result.distanceA).toBeGreaterThanOrEqual(0); + expect(result.distanceA).toBeLessThanOrEqual(Math.sqrt(3)); + expect(result.distanceB).toBeGreaterThanOrEqual(0); + expect(result.distanceB).toBeLessThanOrEqual(Math.sqrt(3)); + }, + ), + ); + }); + + it("reports valid true when declared colors match a uniform border", () => { + fc.assert( + fc.property( + fc.tuple( + fc.integer({ min: 0, max: 255 }), + fc.integer({ min: 0, max: 255 }), + fc.integer({ min: 0, max: 255 }), + ), + fc.integer({ min: 1, max: 16 }), + fc.integer({ min: 1, max: 16 }), + fc.float({ min: 0, max: 1, noNaN: true }), + (srgbColor, width, height, threshold) => { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i += 1) { + data[i * 4] = srgbColor[0]; + data[i * 4 + 1] = srgbColor[1]; + data[i * 4 + 2] = srgbColor[2]; + data[i * 4 + 3] = 255; + } + + const image = createImageData({ width, height, data }); + const measured = measureBackgroundColor(image, 1); + const result = validateBackgroundColors({ + imageA: image, + imageB: image, + backgroundA: measured.mean, + backgroundB: measured.mean, + threshold: Math.max(1e-6, threshold), + }); + + expect(result.distanceA).toBeCloseTo(0, 10); + expect(result.distanceB).toBeCloseTo(0, 10); + expect(result.isValid).toBe(true); + }, + ), + ); + }); +}); diff --git a/tests/validation/background-validation.test.ts b/tests/validation/background-validation.test.ts new file mode 100644 index 0000000..585f179 --- /dev/null +++ b/tests/validation/background-validation.test.ts @@ -0,0 +1,259 @@ +import { describe, expect, it } from "vitest"; +import { + measureBackgroundColor, + validateBackgroundColors, + assertBackgroundColorsValid, + DEFAULT_BACKGROUND_BORDER_WIDTH, + DEFAULT_BACKGROUND_MISMATCH_THRESHOLD, +} from "../../src/validation/background-validation.js"; +import { BackgroundMismatchError } from "../../src/validation/background-errors.js"; +import { createImageData } from "./test-image.js"; + +function createSolidImage( + width: number, + height: number, + color: readonly [number, number, number], + path = "/test/image.png", +): ReturnType { + const data = new Uint8Array(width * height * 4); + for (let i = 0; i < width * height; i += 1) { + data[i * 4] = color[0]; + data[i * 4 + 1] = color[1]; + data[i * 4 + 2] = color[2]; + data[i * 4 + 3] = 255; + } + return createImageData({ width, height, data, path }); +} + +function fillInterior( + image: ReturnType, + color: readonly [number, number, number], + borderWidth: number, +): void { + for (let y = borderWidth; y < image.height - borderWidth; y += 1) { + for (let x = borderWidth; x < image.width - borderWidth; x += 1) { + const index = (y * image.width + x) * 4; + image.data[index] = color[0]; + image.data[index + 1] = color[1]; + image.data[index + 2] = color[2]; + } + } +} + +describe("measureBackgroundColor", () => { + it("returns the correct mean for a solid color", () => { + const image = createSolidImage(16, 16, [255, 255, 255]); + const measured = measureBackgroundColor(image, 4); + + expect(measured.mean[0]).toBeCloseTo(1, 10); + expect(measured.mean[1]).toBeCloseTo(1, 10); + expect(measured.mean[2]).toBeCloseTo(1, 10); + expect(measured.variance[0]).toBeCloseTo(0, 10); + expect(measured.variance[1]).toBeCloseTo(0, 10); + expect(measured.variance[2]).toBeCloseTo(0, 10); + expect(measured.min).toEqual([1, 1, 1]); + expect(measured.max).toEqual([1, 1, 1]); + }); + + it("returns the correct mean for a solid black image", () => { + const image = createSolidImage(16, 16, [0, 0, 0]); + const measured = measureBackgroundColor(image, 4); + + expect(measured.mean).toEqual([0, 0, 0]); + expect(measured.min).toEqual([0, 0, 0]); + expect(measured.max).toEqual([0, 0, 0]); + }); + + it("samples only the border by default", () => { + const image = createSolidImage(16, 16, [255, 255, 255]); + fillInterior(image, [0, 0, 0], 4); + + const measured = measureBackgroundColor(image, 4); + expect(measured.mean).toEqual([1, 1, 1]); + }); + + it("honors the configured border width", () => { + const image = createSolidImage(16, 16, [255, 255, 255]); + fillInterior(image, [0, 0, 0], 6); + + const measuredWide = measureBackgroundColor(image, 4); + expect(measuredWide.mean).toEqual([1, 1, 1]); + + const measuredNarrow = measureBackgroundColor(image, 1); + expect(measuredNarrow.mean).toEqual([1, 1, 1]); + }); + + it("reports the correct sample count", () => { + const width = 16; + const height = 16; + const borderWidth = 4; + const image = createSolidImage(width, height, [255, 255, 255]); + const measured = measureBackgroundColor(image, borderWidth); + + const horizontalPixels = width * borderWidth * 2; + const verticalPixels = (height - borderWidth * 2) * borderWidth * 2; + const expected = horizontalPixels + verticalPixels; + + expect(measured.sampleCount).toBe(expected); + }); + + it("does not modify the input image", () => { + const image = createSolidImage(8, 8, [128, 128, 128]); + const original = new Uint8Array(image.data); + measureBackgroundColor(image, 2); + expect(image.data).toEqual(original); + }); + + it("samples a single-pixel image as the entire border", () => { + const image = createSolidImage(1, 1, [255, 0, 0]); + const measured = measureBackgroundColor(image, 4); + + expect(measured.sampleCount).toBe(1); + expect(measured.mean[0]).toBeCloseTo(1, 10); + expect(measured.mean[1]).toBe(0); + expect(measured.mean[2]).toBe(0); + }); +}); + +describe("validateBackgroundColors", () => { + it("returns isValid true for exact declared colors", () => { + const imageA = createSolidImage(16, 16, [255, 255, 255]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + }); + + expect(result.isValid).toBe(true); + expect(result.distanceA).toBeCloseTo(0, 10); + expect(result.distanceB).toBeCloseTo(0, 10); + }); + + it("returns isValid true for small mismatches within threshold", () => { + const imageA = createSolidImage(16, 16, [254, 254, 254]); + const imageB = createSolidImage(16, 16, [11, 11, 11]); + + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + threshold: 0.1, + }); + + expect(result.isValid).toBe(true); + expect(result.distanceA).toBeGreaterThan(0); + expect(result.distanceB).toBeGreaterThan(0); + }); + + it("returns isValid false for mismatches exceeding threshold", () => { + const imageA = createSolidImage(16, 16, [128, 128, 128]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + threshold: 0.01, + }); + + expect(result.isValid).toBe(false); + expect(result.distanceA).toBeGreaterThan(0.01); + expect(result.distanceB).toBeCloseTo(0, 10); + }); + + it("uses the default threshold when none is provided", () => { + const imageA = createSolidImage(16, 16, [128, 128, 128]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + }); + + expect(result.threshold).toBe(DEFAULT_BACKGROUND_MISMATCH_THRESHOLD); + expect(result.isValid).toBe(false); + }); + + it("uses the default border width when none is provided", () => { + const imageA = createSolidImage(16, 16, [255, 255, 255]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + const result = validateBackgroundColors({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + }); + + expect(result.measuredA.sampleCount).toBe( + measureBackgroundColor(imageA, DEFAULT_BACKGROUND_BORDER_WIDTH).sampleCount, + ); + expect(result.isValid).toBe(true); + }); +}); + +describe("assertBackgroundColorsValid", () => { + it("does not throw for matching colors", () => { + const imageA = createSolidImage(16, 16, [255, 255, 255]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + expect(() => + assertBackgroundColorsValid({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + }), + ).not.toThrow(); + }); + + it("throws BackgroundMismatchError for mismatched colors", () => { + const imageA = createSolidImage(16, 16, [128, 128, 128]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + expect(() => + assertBackgroundColorsValid({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + threshold: 0.01, + }), + ).toThrow(BackgroundMismatchError); + }); + + it("includes measured colors and distances in the error context", () => { + const imageA = createSolidImage(16, 16, [128, 128, 128]); + const imageB = createSolidImage(16, 16, [0, 0, 0]); + + try { + assertBackgroundColorsValid({ + imageA, + imageB, + backgroundA: [1, 1, 1], + backgroundB: [0, 0, 0], + threshold: 0.01, + }); + expect.fail("Expected BackgroundMismatchError"); + } catch (error) { + expect(error).toBeInstanceOf(BackgroundMismatchError); + const mismatchError = error as BackgroundMismatchError; + expect(mismatchError.context).toBeDefined(); + expect(mismatchError.context!.backgroundA).toEqual([1, 1, 1]); + expect(mismatchError.context!.backgroundB).toEqual([0, 0, 0]); + expect(mismatchError.context!.distanceA).toBeGreaterThan(0.01); + expect(mismatchError.context!.distanceB).toBeCloseTo(0, 10); + expect(mismatchError.context!.pathA).toBe(imageA.path); + expect(mismatchError.context!.pathB).toBe(imageB.path); + expect(mismatchError.context!.measuredA).toBeDefined(); + expect(mismatchError.context!.measuredB).toBeDefined(); + } + }); +}); diff --git a/tests/validation/dimension-validator.test.ts b/tests/validation/dimension-validator.test.ts new file mode 100644 index 0000000..dde6d1f --- /dev/null +++ b/tests/validation/dimension-validator.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from "vitest"; +import { validateImageDimensions } from "../../src/validation/index.js"; +import { createImageData } from "./test-image.js"; + +describe("validateImageDimensions", () => { + it("returns no issues when dimensions match", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 2, height: 2 }); + expect(validateImageDimensions(a, b)).toEqual([]); + }); + + it("reports a width mismatch", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 3, height: 2 }); + const issues = validateImageDimensions(a, b); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("DIMENSION_WIDTH_MISMATCH"); + expect(issues[0].severity).toBe("error"); + }); + + it("reports a height mismatch", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 2, height: 3 }); + const issues = validateImageDimensions(a, b); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("DIMENSION_HEIGHT_MISMATCH"); + }); + + it("reports both width and height mismatches", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 3, height: 4 }); + const issues = validateImageDimensions(a, b); + expect(issues).toHaveLength(2); + expect(issues.some((issue) => issue.code === "DIMENSION_WIDTH_MISMATCH")).toBe(true); + expect(issues.some((issue) => issue.code === "DIMENSION_HEIGHT_MISMATCH")).toBe(true); + }); +}); diff --git a/tests/validation/metadata-validator.test.ts b/tests/validation/metadata-validator.test.ts new file mode 100644 index 0000000..965f808 --- /dev/null +++ b/tests/validation/metadata-validator.test.ts @@ -0,0 +1,62 @@ +import { describe, expect, it } from "vitest"; +import { validateImageMetadata } from "../../src/validation/index.js"; +import { createImageData } from "./test-image.js"; + +describe("validateImageMetadata", () => { + it("returns no issues for a valid image", () => { + const image = createImageData({ width: 2, height: 2 }); + expect(validateImageMetadata(image)).toEqual([]); + }); + + it("reports an invalid width of zero", () => { + const image = createImageData({ width: 0, height: 2 }); + const issues = validateImageMetadata(image); + expect(issues).toHaveLength(1); + expect(issues[0].code).toBe("METADATA_INVALID_WIDTH"); + expect(issues[0].severity).toBe("error"); + }); + + it("reports a negative height", () => { + const image = createImageData({ width: 2, height: -1 }); + const issues = validateImageMetadata(image); + expect(issues.some((issue) => issue.code === "METADATA_INVALID_HEIGHT")).toBe(true); + }); + + it("reports non-integer dimensions", () => { + const image = createImageData({ width: 2.5, height: 2.7 }); + const issues = validateImageMetadata(image); + expect(issues.some((issue) => issue.code === "METADATA_INVALID_WIDTH")).toBe(true); + expect(issues.some((issue) => issue.code === "METADATA_INVALID_HEIGHT")).toBe(true); + }); + + it("reports an invalid channel count", () => { + const image = createImageData({ width: 2, height: 2, channels: 3 }); + const issues = validateImageMetadata(image); + expect(issues.some((issue) => issue.code === "METADATA_INVALID_CHANNELS")).toBe(true); + }); + + it("reports an unsupported pixel format", () => { + const image = createImageData({ width: 2, height: 2, format: "rgb8" }); + const issues = validateImageMetadata(image); + expect(issues.some((issue) => issue.code === "METADATA_INVALID_FORMAT")).toBe(true); + }); + + it("reports a pixel buffer size mismatch", () => { + const image = createImageData({ width: 2, height: 2, data: new Uint8Array(4) }); + const issues = validateImageMetadata(image); + expect(issues.some((issue) => issue.code === "METADATA_BUFFER_SIZE_MISMATCH")).toBe(true); + }); + + it("reports multiple metadata issues at once", () => { + const image = createImageData({ + width: 0, + height: 0, + channels: 3, + format: "rgb8", + data: new Uint8Array(0), + }); + const issues = validateImageMetadata(image); + expect(issues.length).toBeGreaterThan(2); + expect(issues.every((issue) => issue.validator === "metadata")).toBe(true); + }); +}); diff --git a/tests/validation/test-image.ts b/tests/validation/test-image.ts new file mode 100644 index 0000000..0d384b0 --- /dev/null +++ b/tests/validation/test-image.ts @@ -0,0 +1,33 @@ +import type { ImageData } from "../../src/io/index.js"; + +/** + * Create a synthetic ImageData object for validation tests. + * + * Does not perform any real decoding; it is only useful for unit tests + * that need to inspect metadata and dimensions. + */ +export function createImageData(options: { + width: number; + height: number; + channels?: number; + format?: string; + data?: Uint8Array; + path?: string; +}): ImageData { + const width = options.width; + const height = options.height; + const channels = options.channels ?? 4; + const format = (options.format ?? "rgba8") as "rgba8"; + const expectedSize = Math.max(0, width * height * channels); + const data = options.data ?? new Uint8Array(expectedSize); + const path = options.path ?? "/test/image.png"; + + return { + width, + height, + channels, + format, + data, + path, + }; +} diff --git a/tests/validation/validate-images.test.ts b/tests/validation/validate-images.test.ts new file mode 100644 index 0000000..e902d74 --- /dev/null +++ b/tests/validation/validate-images.test.ts @@ -0,0 +1,71 @@ +import { describe, expect, it } from "vitest"; +import { + assertImagesValid, + DimensionValidationError, + MetadataValidationError, + validateImages, +} from "../../src/validation/index.js"; +import { createImageData } from "./test-image.js"; + +describe("validateImages", () => { + it("returns a valid result for two matching images", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 2, height: 2 }); + const result = validateImages(a, b); + expect(result.isValid).toBe(true); + expect(result.issues).toEqual([]); + }); + + it("aggregates metadata issues from both images", () => { + const a = createImageData({ width: 2, height: 2, channels: 3 }); + const b = createImageData({ width: 2, height: 2, format: "rgb8" }); + const result = validateImages(a, b); + expect(result.isValid).toBe(false); + expect(result.issues.every((issue) => issue.validator === "metadata")).toBe(true); + expect(result.issues.length).toBeGreaterThanOrEqual(2); + }); + + it("aggregates dimension issues", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 3, height: 4 }); + const result = validateImages(a, b); + expect(result.isValid).toBe(false); + expect(result.issues.every((issue) => issue.validator === "dimensions")).toBe(true); + expect(result.issues).toHaveLength(2); + }); + + it("aggregates both metadata and dimension issues", () => { + const a = createImageData({ width: 0, height: 2 }); + const b = createImageData({ width: 3, height: 2 }); + const result = validateImages(a, b); + expect(result.isValid).toBe(false); + expect(result.issues.some((issue) => issue.validator === "metadata")).toBe(true); + expect(result.issues.some((issue) => issue.validator === "dimensions")).toBe(true); + }); +}); + +describe("assertImagesValid", () => { + it("does not throw for valid images", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 2, height: 2 }); + expect(() => assertImagesValid(a, b)).not.toThrow(); + }); + + it("throws DimensionValidationError for dimension mismatches", () => { + const a = createImageData({ width: 2, height: 2 }); + const b = createImageData({ width: 3, height: 2 }); + expect(() => assertImagesValid(a, b)).toThrow(DimensionValidationError); + }); + + it("throws MetadataValidationError for metadata issues", () => { + const a = createImageData({ width: 0, height: 2 }); + const b = createImageData({ width: 2, height: 2 }); + expect(() => assertImagesValid(a, b)).toThrow(MetadataValidationError); + }); + + it("throws MetadataValidationError when metadata and dimensions both fail", () => { + const a = createImageData({ width: 0, height: 2 }); + const b = createImageData({ width: 3, height: 4 }); + expect(() => assertImagesValid(a, b)).toThrow(MetadataValidationError); + }); +}); diff --git a/tsconfig.eslint.json b/tsconfig.eslint.json new file mode 100644 index 0000000..0680ca6 --- /dev/null +++ b/tsconfig.eslint.json @@ -0,0 +1,9 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "rootDir": ".", + "allowJs": true, + "noEmit": true + }, + "include": ["src/**/*", "tests/**/*", "vitest.config.ts", "eslint.config.js"] +} diff --git a/tsconfig.json b/tsconfig.json new file mode 100644 index 0000000..bad8cf5 --- /dev/null +++ b/tsconfig.json @@ -0,0 +1,25 @@ +{ + "compilerOptions": { + "target": "ES2022", + "module": "NodeNext", + "moduleResolution": "NodeNext", + "lib": ["ES2022"], + "outDir": "dist", + "rootDir": "src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "forceConsistentCasingInFileNames": true, + "declaration": true, + "declarationMap": true, + "sourceMap": true, + "isolatedModules": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true, + "verbatimModuleSyntax": true + }, + "include": ["src/**/*"], + "exclude": ["node_modules", "dist"] +} diff --git a/vitest.config.ts b/vitest.config.ts new file mode 100644 index 0000000..8a00956 --- /dev/null +++ b/vitest.config.ts @@ -0,0 +1,9 @@ +import { defineConfig } from "vitest/config"; + +export default defineConfig({ + test: { + globals: false, + environment: "node", + include: ["tests/**/*.test.ts"], + }, +});