diff --git a/docs/svelte-migration/ARCHITECTURE.md b/docs/svelte-migration/ARCHITECTURE.md index 1c2b506..8f3c3fe 100644 --- a/docs/svelte-migration/ARCHITECTURE.md +++ b/docs/svelte-migration/ARCHITECTURE.md @@ -106,6 +106,8 @@ type SliceView = { image: AssetRef; // raw RGBA slice thumbnail: AssetRef; // checkerboard composite for display mask: AssetRef | null; // RGBA (r, 0, 0, r); null when no mask is saved + isGround: boolean; // the horizontal ground plane, not a vertical card + isRest: boolean; // the "rest of image" slice (at most one); see `slices/create` below }; type InpaintingView = { model: string; strength: number; guidanceScale: number; @@ -185,10 +187,10 @@ same project-lock `_mutation_guard` rather than as a background job. | Method & path | Body | Result | | --- | --- | --- | -| `POST /api/v1/projects/{id}/slices/create` | `{}` | `200 ProjectView & {changed: true}` (sync); `create_slice` - a new slice from the current mask (or an empty transparent one with none), appended, selected, and saved (JSON + the new slice's own image). | -| `DELETE /api/v1/projects/{id}/slices/{index}` | – | `200 ProjectView & {changed: true}` (sync); `delete_slice` - out-of-range `index` is `400 invalid_request`; the selection is always cleared. | -| `POST /api/v1/projects/{id}/slices/{index}/add-mask` | `{}` | `200 ProjectView & {changed: true}` (sync); `add_mask_to_slice`. `index` must equal the project's current `selectedSlice` (`400 invalid_request` otherwise, since the service itself has no index parameter - it always operates on the selection, exactly like Dash). | -| `POST /api/v1/projects/{id}/slices/{index}/remove-mask` | `{}` | Same shape and `index`-must-match-selection rule as add-mask, calling `remove_mask_from_slice`. | +| `POST /api/v1/projects/{id}/slices/create` | `{}` | `200 ProjectView & {changed: true}` (sync); `create_slice` - a new slice from the current mask (or an empty transparent one with none), appended, selected, and saved (JSON + the new slice's own image). When Segment Anything cuts the *first* object slice out of a project with no slices yet, a farthest "rest of image" slice is also created (`SliceView.isRest`): the input image with alpha `255 - object.alpha`, depth 0, logged as `"Created image_slice_N with the rest of the image"`. The selection stays on the object slice even though the rest slice's depth-0 insertion can shift its index. If a rest slice already exists, its alpha is instead reduced by the new object's footprint (`rest.alpha = min(rest.alpha, 255 - object.alpha)`), saved as a new (undoable) version. A project split by depth first (slices exist, none is the rest) behaves exactly as before - no rest slice is added. An empty create (no mask) never creates or changes the rest slice. Persisted as `rest_slice` in the project JSON, the same way `ground_plane_slice` is. | +| `DELETE /api/v1/projects/{id}/slices/{index}` | – | `200 ProjectView & {changed: true}` (sync); `delete_slice` - out-of-range `index` is `400 invalid_request`; the selection is always cleared. When a rest slice exists and an *object* slice is deleted, pixels the deleted slice covered that no remaining (non-rest) slice covers either get their rest alpha back to 255, with RGB refilled from the input image only where the rest slice was fully transparent; every other rest pixel - including any inpainting done directly on it - is left alone, and the rest slice is saved as a new version. Deleting the rest slice itself removes it like any other slice; none is recreated automatically. | +| `POST /api/v1/projects/{id}/slices/{index}/add-mask` | `{}` | `200 ProjectView & {changed: true}` (sync); `add_mask_to_slice`. `index` must equal the project's current `selectedSlice` (`400 invalid_request` otherwise, since the service itself has no index parameter - it always operates on the selection, exactly like Dash). If a rest slice exists and the edited slice is an object slice, the rest slice's alpha is reduced by the added area the same way `slices/create` does, and saved as a new version. | +| `POST /api/v1/projects/{id}/slices/{index}/remove-mask` | `{}` | Same shape and `index`-must-match-selection rule as add-mask, calling `remove_mask_from_slice`. If a rest slice exists and the edited slice is an object slice, wherever the mask lowered the object's alpha and no other (non-rest) slice still covers the pixel, the rest alpha rises to `255 - object.alpha` (so a feathered removal restores only what it removed), the same way slice deletion does. | | `POST /api/v1/projects/{id}/clipboard/copy` | `{}` | `200 ProjectView & {changed: true}` (sync); `copy_to_clipboard` - requires a mask (`409 not_ready` otherwise); no save, no display change (matches Dash exactly: `copy_to_clipboard` has no `STORE_UPDATE_SLICE` output). | | `POST /api/v1/projects/{id}/clipboard/paste` | `{}` | `200 ProjectView & {changed: true}` (sync); `paste_clipboard` - requires both a populated clipboard and a selected slice (`409 not_ready` otherwise); the clipboard survives the paste. | | `POST /api/v1/projects/{id}/slices/balance` | `{}` | `200 ProjectView & {changed}` (sync); `balance_slices` - `changed: false` (not an error) with zero slices, matching `WorkflowUnchanged`'s pattern. Implements the *fixed*, intended behavior; the equivalent Dash button is currently unreachable (500s) due to an unrelated, pre-existing `webui.py` bug - see PARITY.md "Known quirks". | diff --git a/e2e/drivers/svelte.ts b/e2e/drivers/svelte.ts index ae8856c..e8273a1 100644 --- a/e2e/drivers/svelte.ts +++ b/e2e/drivers/svelte.ts @@ -108,6 +108,14 @@ export class SvelteDriver implements UiDriver { } } + /** Split by depth is a collapsible section of the Slices panel. */ + private async openSplitByDepth(): Promise { + const toggle = this.page.getByTestId('split-toggle'); + if (!(await toggle.isVisible())) await this.openTab('Segmentation'); + if ((await toggle.getAttribute('aria-expanded')) !== 'true') await toggle.click(); + await expect(toggle).toHaveAttribute('aria-expanded', 'true'); + } + /** Per-slice actions live in the Inspector's header for the selected slice. */ private async ensureSelected(index: number): Promise { const row = this.sliceRow(index); @@ -189,6 +197,7 @@ export class SvelteDriver implements UiDriver { } async generateSlices(): Promise { + await this.openSplitByDepth(); await this.page.getByTestId('generate-slices').click(); } @@ -440,6 +449,7 @@ export class SvelteDriver implements UiDriver { } async balanceSlices(): Promise { + await this.openSplitByDepth(); await this.page.getByTestId('balance-slices').click(); } diff --git a/frontend/src/lib/api/generated.ts b/frontend/src/lib/api/generated.ts index 33ac8f8..6941015 100644 --- a/frontend/src/lib/api/generated.ts +++ b/frontend/src/lib/api/generated.ts @@ -95,6 +95,7 @@ export type Canundo = boolean; export type Depth = number; export type Index1 = number; export type Isground = boolean; +export type Isrest = boolean; export type Negativeprompt2 = string; export type Positiveprompt2 = string; export type Version1 = number; @@ -506,6 +507,7 @@ export interface SliceView { image: AssetRef; index: Index1; isGround?: Isground; + isRest?: Isrest; mask?: AssetRef | null; negativePrompt: Negativeprompt2; positivePrompt: Positiveprompt2; diff --git a/frontend/src/lib/api/schema.json b/frontend/src/lib/api/schema.json index 74dee89..ed90360 100644 --- a/frontend/src/lib/api/schema.json +++ b/frontend/src/lib/api/schema.json @@ -1350,6 +1350,11 @@ "title": "Isground", "type": "boolean" }, + "isRest": { + "default": false, + "title": "Isrest", + "type": "boolean" + }, "mask": { "anyOf": [ { diff --git a/frontend/src/lib/components/canvas/CanvasArea.svelte b/frontend/src/lib/components/canvas/CanvasArea.svelte index 3d9bf3f..ebefa93 100644 --- a/frontend/src/lib/components/canvas/CanvasArea.svelte +++ b/frontend/src/lib/components/canvas/CanvasArea.svelte @@ -13,6 +13,7 @@ import ViewModeBar from './ViewModeBar.svelte'; import ToolOptionsBar from './ToolOptionsBar.svelte'; import CanvasToolbar from './CanvasToolbar.svelte'; + import SelectionBar from './SelectionBar.svelte'; let stage: ReturnType | undefined = $state(); @@ -40,7 +41,11 @@ {/if}
-
+
@@ -48,6 +53,9 @@
{#if toolsShown} + {#if uiStore.tool === 'segment' && uiStore.view === 'input'} + + {/if} {/if}
@@ -82,4 +90,10 @@ padding-left: 56px; padding-right: 56px; } + + /* With the Segment tool, keep the selection bar's strip clear so the bar + never covers the image (and the image doesn't jump when it appears). */ + .stage.with-selection-bar { + padding-bottom: 60px; + } diff --git a/frontend/src/lib/components/canvas/SelectionBar.svelte b/frontend/src/lib/components/canvas/SelectionBar.svelte new file mode 100644 index 0000000..4f13e45 --- /dev/null +++ b/frontend/src/lib/components/canvas/SelectionBar.svelte @@ -0,0 +1,97 @@ + + +{#if hasMask || queued > 0} + +{/if} + + diff --git a/frontend/src/lib/components/canvas/SelectionBar.test.ts b/frontend/src/lib/components/canvas/SelectionBar.test.ts new file mode 100644 index 0000000..1cc284e --- /dev/null +++ b/frontend/src/lib/components/canvas/SelectionBar.test.ts @@ -0,0 +1,288 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import SelectionBar from './SelectionBar.svelte'; +import { projectStore } from '../../state/project.svelte'; +import { jobStore } from '../../state/jobs.svelte'; +import { logStore } from '../../state/logs.svelte'; +import type { ProjectView, SliceView } from '../../api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeSlice(index: number, depth: number): SliceView { + return { + index, + depth, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + }; +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [0, 85, 170, 255], + slices: [], + selectedSlice: null, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, + inpainting: { + model: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', + strength: 0.8, + guidanceScale: 7.5, + padding: 50, + blur: 50, + externalServer: 'localhost:7860', + hasWorkflow: false, + candidates: null, + selectedCandidate: null, + }, + busy: null, + settings: { + darkMode: false, + camera: { distance: 100, focalLength: 100, maxDistance: 200 }, + meshDisplacement: 0, + depthModel: 'dinov2', + }, + exports: { gltf: null, upscaled: false }, + ...overrides, + }; +} + +/** A fetch stub that answers known URLs and auto-handles the log refresh; throws otherwise. */ +function makeFetchMock(handlers: Record Response>) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + const handler = handlers[url]; + if (handler) return handler(init); + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); +} + +describe('SelectionBar', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('is not rendered without a mask or queued points', () => { + projectStore.applyView(makeView()); + render(SelectionBar); + expect(screen.queryByTestId('selection-bar')).toBeNull(); + }); + + it('is not rendered for queued points outside multi-point mode', () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: false, queuedPoints: [{ x: 1, y: 2, negative: false }], hasMask: false }, + }), + ); + render(SelectionBar); + expect(screen.queryByTestId('selection-bar')).toBeNull(); + }); + + describe('multi-point queue', () => { + it('shows the queued count and a pluralized Segment N points button', () => { + projectStore.applyView( + makeView({ + segmentation: { + multiPointMode: true, + queuedPoints: [{ x: 1, y: 2, negative: false }, { x: 3, y: 4, negative: false }], + hasMask: false, + }, + }), + ); + render(SelectionBar); + expect(screen.getByTestId('selection-bar')).toBeInTheDocument(); + expect(screen.getByTestId('selection-bar')).toHaveTextContent('2 points'); + expect(screen.getByTestId('selection-commit')).toHaveTextContent('Segment 2 points'); + expect(screen.queryByTestId('selection-add')).toBeNull(); + expect(screen.queryByTestId('selection-remove')).toBeNull(); + expect(screen.queryByTestId('selection-new-slice')).toBeNull(); + }); + + it('uses the singular for exactly one queued point', () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: true, queuedPoints: [{ x: 1, y: 2, negative: false }], hasMask: false }, + }), + ); + render(SelectionBar); + expect(screen.getByTestId('selection-bar')).toHaveTextContent('1 point'); + expect(screen.getByTestId('selection-commit')).toHaveTextContent('Segment 1 point'); + }); + + it('disables Segment N points while busy', async () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: true, queuedPoints: [{ x: 1, y: 2, negative: false }], hasMask: false }, + }), + ); + render(SelectionBar); + expect(screen.getByTestId('selection-commit')).toBeEnabled(); + + jobStore.begin('segmentation'); + await waitFor(() => expect(screen.getByTestId('selection-commit')).toBeDisabled()); + }); + + it('commits the queued points via POST .../segmentation/commit', async () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: true, queuedPoints: [{ x: 1, y: 2, negative: false }], hasMask: false }, + }), + ); + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(202, { job: { id: 'job-1', kind: 'segmentation', status: 'queued', progress: 0 } }), + ); + fetchMock.mockResolvedValueOnce( + jsonResponse(200, { + id: 'job-1', + kind: 'segmentation', + status: 'succeeded', + progress: 1, + project: makeView({ + revision: 2, + segmentation: { multiPointMode: true, queuedPoints: [], hasMask: true }, + }), + }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); + + render(SelectionBar); + await fireEvent.click(screen.getByTestId('selection-commit')); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(3)); + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(url).toBe('/api/v1/projects/appstate-test/segmentation/commit'); + expect(init.method).toBe('POST'); + }); + }); + + describe('with a mask', () => { + it('shows the Selection summary, and disables Add/Remove without a selected slice', () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + selectedSlice: null, + }), + ); + render(SelectionBar); + expect(screen.getByTestId('selection-bar')).toHaveTextContent('Selection'); + expect(screen.getByTestId('selection-add')).toHaveTextContent('Add to slice'); + expect(screen.getByTestId('selection-add')).toBeDisabled(); + expect(screen.getByTestId('selection-remove')).toHaveTextContent('Remove from slice'); + expect(screen.getByTestId('selection-remove')).toBeDisabled(); + expect(screen.getByTestId('selection-new-slice')).toBeEnabled(); + expect(screen.queryByTestId('selection-commit')).toBeNull(); + }); + + it('labels Add/Remove with the selected slice name, and enables them', () => { + projectStore.applyView( + makeView({ + slices: [makeSlice(0, 85), makeSlice(2, 170)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + ); + render(SelectionBar); + expect(screen.getByTestId('selection-add')).toHaveTextContent('Add to image_slice_2'); + expect(screen.getByTestId('selection-add')).toBeEnabled(); + expect(screen.getByTestId('selection-remove')).toHaveTextContent('Remove from image_slice_2'); + expect(screen.getByTestId('selection-remove')).toBeEnabled(); + }); + + it('disables Add/Remove/New slice while busy', async () => { + projectStore.applyView( + makeView({ + slices: [makeSlice(2, 170)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + ); + render(SelectionBar); + jobStore.begin('slice-editing'); + await waitFor(() => { + expect(screen.getByTestId('selection-add')).toBeDisabled(); + expect(screen.getByTestId('selection-remove')).toBeDisabled(); + expect(screen.getByTestId('selection-new-slice')).toBeDisabled(); + }); + }); + + it('calls addMaskToSlice, removeMaskFromSlice and createSlice', async () => { + projectStore.applyView( + makeView({ + slices: [makeSlice(2, 170)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + ); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/2/add-mask': () => + jsonResponse(200, { + ...makeView({ + slices: [makeSlice(2, 170)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + changed: true, + }), + '/api/v1/projects/appstate-test/slices/2/remove-mask': () => + jsonResponse(200, { + ...makeView({ + slices: [makeSlice(2, 170)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + changed: true, + }), + '/api/v1/projects/appstate-test/slices/create': () => + jsonResponse(200, { + ...makeView({ + slices: [makeSlice(2, 170), makeSlice(3, 200)], + selectedSlice: 2, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true }, + }), + changed: true, + }), + }); + vi.stubGlobal('fetch', fetchMock); + render(SelectionBar); + + await fireEvent.click(screen.getByTestId('selection-add')); + await waitFor(() => + expect(fetchMock.mock.calls.some(([u]) => String(u).endsWith('/add-mask'))).toBe(true), + ); + + await fireEvent.click(screen.getByTestId('selection-remove')); + await waitFor(() => + expect(fetchMock.mock.calls.some(([u]) => String(u).endsWith('/remove-mask'))).toBe(true), + ); + + await fireEvent.click(screen.getByTestId('selection-new-slice')); + await waitFor(() => + expect(fetchMock.mock.calls.some(([u]) => String(u).endsWith('/slices/create'))).toBe(true), + ); + }); + }); +}); diff --git a/frontend/src/lib/components/config/ConfigurationTab.svelte b/frontend/src/lib/components/config/ConfigurationTab.svelte index 15b67f7..fae1a7f 100644 --- a/frontend/src/lib/components/config/ConfigurationTab.svelte +++ b/frontend/src/lib/components/config/ConfigurationTab.svelte @@ -15,13 +15,6 @@ import HelpTooltip from '../shared/HelpTooltip.svelte'; import { CONFIGURATION_HELP_TEXTS } from '../../helpTexts'; - // Keep the slider in sync with the project once one exists (including - // after a restore), the same way Dash's slider reflects state. - $effect(() => { - const view = projectStore.view; - if (view) uiStore.setPendingNumSlices(view.numSlices); - }); - // Same options/values/default as components.py's DROPDOWN_INPAINT_MODEL. const inpaintModelOptions: Array<{ value: string; label: string }> = [ { value: 'kandinsky-community/kandinsky-2-2-decoder-inpaint', label: 'Kandinsky' }, @@ -61,12 +54,6 @@ void workflow.updateInpaintingSettings({ blur: value }); } - function onNumSlicesChange(event: Event): void { - const value = Number((event.currentTarget as HTMLInputElement).value); - uiStore.setPendingNumSlices(value); - if (projectStore.view) void workflow.updateSliceCount(value); - } - let restoreInput: HTMLInputElement | undefined; function onRestoreChange(event: Event): void { @@ -126,26 +113,6 @@
-
- - -
- {#each Array.from({ length: 9 }, (_, i) => i + 2) as mark (mark)} - {mark} - {/each} -
-
-
+ {uiStore.pendingNumSlices} +
+
+ {#each localValues as value, index (index)} +
+ Band {index + 1} + onThresholdInput(index, event)} + onchange={() => void workflow.updateThresholds([...localValues])} + /> + {value} +
+ {/each} +
+
+ + +
+
+ + + {#if selected} +
+

Selected slice

+
+ + + +
+
+ + +
+
+ {/if} + + + diff --git a/frontend/src/lib/components/segmentation/SegmentPanel.test.ts b/frontend/src/lib/components/segmentation/SegmentPanel.test.ts new file mode 100644 index 0000000..bf7a476 --- /dev/null +++ b/frontend/src/lib/components/segmentation/SegmentPanel.test.ts @@ -0,0 +1,387 @@ +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; +import SegmentPanel from './SegmentPanel.svelte'; +import { projectStore } from '../../state/project.svelte'; +import { jobStore } from '../../state/jobs.svelte'; +import { logStore } from '../../state/logs.svelte'; +import { uiStore } from '../../state/ui.svelte'; +import type { ProjectView, SliceView } from '../../api/types'; + +function jsonResponse(status: number, body: unknown): Response { + return new Response(JSON.stringify(body), { + status, + headers: { 'Content-Type': 'application/json' }, + }); +} + +function makeSlice(index: number, depth: number): SliceView { + return { + index, + depth, + version: 1, + canUndo: false, + canRedo: false, + positivePrompt: '', + negativePrompt: '', + image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, + thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, + }; +} + +function makeView(overrides: Partial = {}): ProjectView { + return { + id: 'appstate-test', + revision: 1, + image: { width: 320, height: 240 }, + assets: { input: { url: '/input' }, depth: { url: '/depth' } }, + depthModel: 'dinov2', + numSlices: 3, + thresholds: [0, 85, 170, 255], + slices: [], + selectedSlice: null, + segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, + inpainting: { + model: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', + strength: 0.8, + guidanceScale: 7.5, + padding: 50, + blur: 50, + externalServer: 'localhost:7860', + hasWorkflow: false, + candidates: null, + selectedCandidate: null, + }, + busy: null, + settings: { + darkMode: false, + camera: { distance: 100, focalLength: 100, maxDistance: 200 }, + meshDisplacement: 0, + depthModel: 'dinov2', + }, + exports: { gltf: null, upscaled: false }, + ...overrides, + }; +} + +/** A fetch stub that answers known URLs and auto-handles the log refresh; throws otherwise. */ +function makeFetchMock(handlers: Record Response>) { + return vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + const handler = handlers[url]; + if (handler) return handler(init); + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); +} + +describe('SegmentPanel', () => { + beforeEach(() => { + projectStore.reset(); + jobStore.end(); + logStore.reset(); + uiStore.reset(); + }); + + afterEach(() => { + vi.unstubAllGlobals(); + }); + + it('disables the Generate button while a job is in flight, and re-enables it after', async () => { + projectStore.applyView(makeView()); + render(SegmentPanel); + + expect(screen.getByTestId('generate-slices')).toBeEnabled(); + + jobStore.begin('slices'); + await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeDisabled()); + + jobStore.end(); + await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeEnabled()); + }); + + it('sends baseRevision on threshold change, and retries once on stale_revision', async () => { + projectStore.applyView(makeView()); + + const fetchMock = vi.fn(); + fetchMock.mockResolvedValueOnce( + jsonResponse(409, { error: { code: 'stale_revision', message: 'stale' } }), + ); + fetchMock.mockResolvedValueOnce(jsonResponse(200, makeView({ revision: 2 }))); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { ...makeView({ revision: 3 }), changed: true })); + fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentPanel); + const [firstHandle] = screen.getAllByTestId('threshold-handle'); + await fireEvent.input(firstHandle, { target: { value: '90' } }); + await fireEvent.change(firstHandle, { target: { value: '90' } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); + + const [firstUrl, firstInit] = fetchMock.mock.calls[0] as [string, RequestInit]; + expect(firstUrl).toBe('/api/v1/projects/appstate-test/thresholds'); + expect(firstInit.method).toBe('PUT'); + expect(JSON.parse(firstInit.body as string)).toMatchObject({ baseRevision: 1, values: [90, 170] }); + + const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; + expect(secondUrl).toBe('/api/v1/projects/appstate-test'); + + const [thirdUrl, thirdInit] = fetchMock.mock.calls[2] as [string, RequestInit]; + expect(thirdUrl).toBe('/api/v1/projects/appstate-test/thresholds'); + expect(JSON.parse(thirdInit.body as string)).toMatchObject({ baseRevision: 2, values: [90, 170] }); + }); + + describe('Actions panel enablement', () => { + // Matches Dash exactly: webui.py never disables Create/Add/Remove/Balance + // based on selection/mask state -- only "is a project loaded" and "is + // nothing else in flight" gate them, same as Generate. Copy/Paste/Delete + // moved to LayerPanel's header (see layers/LayerPanel.test.ts). + // Preconditions are enforced by workflow.ts at click time. + const actionTestIds = ['balance-slices', 'create-slice', 'add-mask-to-slice', 'remove-mask-from-slice']; + + it('disables every action button when there is no project', () => { + render(SegmentPanel); + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + + it('enables every action button with a project loaded, regardless of selection or mask state', () => { + projectStore.applyView( + makeView({ selectedSlice: null, segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false } }), + ); + render(SegmentPanel); + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeEnabled(); + } + }); + + it('disables every action button while a job is in flight', async () => { + projectStore.applyView(makeView()); + render(SegmentPanel); + jobStore.begin('slice-editing'); + await waitFor(() => { + for (const testId of actionTestIds) { + expect(screen.getByTestId(testId)).toBeDisabled(); + } + }); + }); + }); + + describe('Segment objects sub-steps and summary', () => { + it('starts at step 1 (click an object) with nothing selected', () => { + projectStore.applyView(makeView()); + render(SegmentPanel); + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('data-state', 'now'); + expect(items[1]).toHaveAttribute('data-state', 'todo'); + expect(items[2]).toHaveAttribute('data-state', 'todo'); + expect(screen.getByTestId('selection-summary')).toHaveTextContent('Nothing selected yet'); + }); + + it('moves to step 2 (refine) once points are queued, and pluralizes the summary', () => { + projectStore.applyView( + makeView({ + segmentation: { + multiPointMode: true, + queuedPoints: [{ x: 1, y: 2, negative: false }, { x: 3, y: 4, negative: false }], + hasMask: false, + }, + }), + ); + render(SegmentPanel); + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('data-state', 'done'); + expect(items[1]).toHaveAttribute('data-state', 'now'); + expect(items[2]).toHaveAttribute('data-state', 'todo'); + expect(screen.getByTestId('selection-summary')).toHaveTextContent('2 points queued'); + }); + + it('uses the singular for exactly one queued point', () => { + projectStore.applyView( + makeView({ + segmentation: { multiPointMode: true, queuedPoints: [{ x: 1, y: 2, negative: false }], hasMask: false }, + }), + ); + render(SegmentPanel); + expect(screen.getByTestId('selection-summary')).toHaveTextContent('1 point queued'); + }); + + it('moves to step 3 (make it a slice) once a mask exists, regardless of queued points', () => { + projectStore.applyView( + makeView({ segmentation: { multiPointMode: false, queuedPoints: [], hasMask: true } }), + ); + render(SegmentPanel); + const items = screen.getAllByRole('listitem'); + expect(items[0]).toHaveAttribute('data-state', 'done'); + expect(items[1]).toHaveAttribute('data-state', 'done'); + expect(items[2]).toHaveAttribute('data-state', 'now'); + expect(screen.getByTestId('selection-summary')).toHaveTextContent('Selection ready'); + }); + }); + + describe('Split by depth', () => { + it('is open by default while there are no slices', () => { + projectStore.applyView(makeView({ slices: [] })); + render(SegmentPanel); + expect(screen.getByTestId('split-toggle')).toHaveAttribute('aria-expanded', 'true'); + }); + + it('is closed by default once slices exist', () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)] })); + render(SegmentPanel); + expect(screen.getByTestId('split-toggle')).toHaveAttribute('aria-expanded', 'false'); + }); + + it('toggles open/closed on click, overriding the default', async () => { + projectStore.applyView(makeView({ slices: [] })); + const { container } = render(SegmentPanel); + const toggle = screen.getByTestId('split-toggle'); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(container.querySelector('.split-body')?.classList.contains('hidden')).toBe(false); + + await fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'false'); + expect(container.querySelector('.split-body')?.classList.contains('hidden')).toBe(true); + + await fireEvent.click(toggle); + expect(toggle).toHaveAttribute('aria-expanded', 'true'); + expect(container.querySelector('.split-body')?.classList.contains('hidden')).toBe(false); + }); + + it('PUTs the new slice count on change', async () => { + projectStore.applyView(makeView({ numSlices: 3 })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slice-count': () => + jsonResponse(200, makeView({ numSlices: 5 })), + }); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentPanel); + await fireEvent.change(screen.getByTestId('num-slices'), { target: { value: '5' } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slice-count')); + expect(call![1]).toMatchObject({ method: 'PUT' }); + expect(JSON.parse(call![1]!.body as string)).toEqual({ numSlices: 5 }); + expect(uiStore.pendingNumSlices).toBe(5); + }); + }); + + describe('Selected slice section', () => { + it('is not shown without a selected slice', () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)], selectedSlice: null })); + render(SegmentPanel); + expect(screen.queryByTestId('selected-slice-section')).toBeNull(); + }); + + it('is shown once a slice is selected', () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)], selectedSlice: 0 })); + render(SegmentPanel); + expect(screen.getByTestId('selected-slice-section')).toBeInTheDocument(); + }); + + it('commits the new depth on change', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)], selectedSlice: 0 })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/0/depth': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 120)], selectedSlice: 0 }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentPanel); + await fireEvent.change(screen.getByTestId('selected-depth-slider'), { target: { value: '120' } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/depth')); + expect(call![1]).toMatchObject({ method: 'PUT' }); + expect(JSON.parse(call![1]!.body as string)).toEqual({ depth: 120 }); + }); + + it('clamps the committed depth to 0-255 from either the slider or the number field', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)], selectedSlice: 0 })); + const fetchMock = makeFetchMock({ + '/api/v1/projects/appstate-test/slices/0/depth': () => + jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 255)], selectedSlice: 0 }), changed: true }), + }); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentPanel); + await fireEvent.change(screen.getByTestId('selected-depth-input'), { target: { value: '999' } }); + + await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(2)); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/depth')); + expect(JSON.parse(call![1]!.body as string)).toEqual({ depth: 255 }); + }); + + it('does not commit when the value is unchanged', async () => { + projectStore.applyView(makeView({ slices: [makeSlice(0, 85)], selectedSlice: 0 })); + const fetchMock = vi.fn(); + vi.stubGlobal('fetch', fetchMock); + + render(SegmentPanel); + await fireEvent.change(screen.getByTestId('selected-depth-slider'), { target: { value: '85' } }); + + expect(fetchMock).not.toHaveBeenCalled(); + }); + + describe('ground plane', () => { + it('marks the selected slice as the ground and fits it', async () => { + const ground = { ...makeSlice(1, 170), isGround: true }; + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { + const url = String(input); + if (url === '/api/v1/projects/appstate-test/slices/1/ground') { + return jsonResponse(200, { + ...makeView({ slices: [makeSlice(0, 85), ground], selectedSlice: 1 }), + changed: true, + }); + } + if (url === '/api/v1/projects/appstate-test/ground/fit') { + return jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85), ground] }), changed: true }); + } + if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); + throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); + }); + vi.stubGlobal('fetch', fetchMock); + render(SegmentPanel); + + expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'false'); + expect(screen.getByTestId('ground-toggle')).toHaveTextContent('Make ground plane'); + expect(screen.getByTestId('ground-fit')).toBeDisabled(); + await fireEvent.click(screen.getByTestId('ground-toggle')); + + await waitFor(() => expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'true')); + expect(screen.getByTestId('ground-toggle')).toHaveTextContent('Ground plane'); + const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/ground')); + expect(JSON.parse(call![1]!.body as string)).toEqual({ isGround: true }); + + await waitFor(() => expect(screen.getByTestId('ground-fit')).toBeEnabled()); + await fireEvent.click(screen.getByTestId('ground-fit')); + await waitFor(() => + expect(fetchMock).toHaveBeenCalledWith( + '/api/v1/projects/appstate-test/ground/fit', + expect.objectContaining({ method: 'POST' }), + ), + ); + }); + + it('disables Fit ground until a ground slice exists', () => { + projectStore.applyView( + makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), + ); + render(SegmentPanel); + expect(screen.getByTestId('ground-fit')).toBeDisabled(); + }); + + it('enables Fit ground once a ground slice exists', () => { + const ground = { ...makeSlice(1, 170), isGround: true }; + projectStore.applyView(makeView({ slices: [makeSlice(0, 85), ground], selectedSlice: 0 })); + render(SegmentPanel); + expect(screen.getByTestId('ground-fit')).toBeEnabled(); + }); + }); + }); +}); diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.svelte b/frontend/src/lib/components/segmentation/SegmentationTab.svelte deleted file mode 100644 index dfdf826..0000000 --- a/frontend/src/lib/components/segmentation/SegmentationTab.svelte +++ /dev/null @@ -1,174 +0,0 @@ - - -
-
- -
-
-
- Thresholds -
- {#each localValues as value, index (index)} -
- onSliderInput(index, event)} - onchange={onSliderChange} - /> - - {value} -
- {/each} -
-
- -
- Actions -
- {#each actions as action (action.label)} - - {/each} - - -
-
-
-
- - diff --git a/frontend/src/lib/components/segmentation/SegmentationTab.test.ts b/frontend/src/lib/components/segmentation/SegmentationTab.test.ts deleted file mode 100644 index 8dd6e13..0000000 --- a/frontend/src/lib/components/segmentation/SegmentationTab.test.ts +++ /dev/null @@ -1,205 +0,0 @@ -import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; -import { render, screen, fireEvent, waitFor } from '@testing-library/svelte'; -import SegmentationTab from './SegmentationTab.svelte'; -import { projectStore } from '../../state/project.svelte'; -import { jobStore } from '../../state/jobs.svelte'; -import { logStore } from '../../state/logs.svelte'; -import type { ProjectView, SliceView } from '../../api/types'; - -function jsonResponse(status: number, body: unknown): Response { - return new Response(JSON.stringify(body), { - status, - headers: { 'Content-Type': 'application/json' }, - }); -} - -function makeSlice(index: number, depth: number): SliceView { - return { - index, - depth, - version: 1, - canUndo: false, - canRedo: false, - positivePrompt: '', - negativePrompt: '', - image: { url: `/api/v1/projects/appstate-test/assets/slice-${index}` }, - thumbnail: { url: `/api/v1/projects/appstate-test/assets/slice-${index}-thumb` }, - }; -} - -function makeView(overrides: Partial = {}): ProjectView { - return { - id: 'appstate-test', - revision: 1, - image: { width: 320, height: 240 }, - assets: { input: { url: '/input' }, depth: { url: '/depth' } }, - depthModel: 'dinov2', - numSlices: 3, - thresholds: [0, 85, 170, 255], - slices: [], - selectedSlice: null, - segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false }, - inpainting: { - model: 'diffusers/stable-diffusion-xl-1.0-inpainting-0.1', - strength: 0.8, - guidanceScale: 7.5, - padding: 50, - blur: 50, - externalServer: 'localhost:7860', - hasWorkflow: false, - candidates: null, - selectedCandidate: null, - }, - busy: null, - settings: { - darkMode: false, - camera: { distance: 100, focalLength: 100, maxDistance: 200 }, - meshDisplacement: 0, - depthModel: "dinov2", - }, - exports: { gltf: null, upscaled: false }, - ...overrides, - }; -} - -describe('SegmentationTab', () => { - beforeEach(() => { - projectStore.reset(); - jobStore.end(); - logStore.reset(); - }); - - afterEach(() => { - vi.unstubAllGlobals(); - }); - - it('disables the Generate button while a job is in flight, and re-enables it after', async () => { - projectStore.applyView(makeView()); - render(SegmentationTab); - - expect(screen.getByTestId('generate-slices')).toBeEnabled(); - - jobStore.begin('slices'); - await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeDisabled()); - - jobStore.end(); - await waitFor(() => expect(screen.getByTestId('generate-slices')).toBeEnabled()); - }); - - it('sends baseRevision on threshold change, and retries once on stale_revision', async () => { - projectStore.applyView(makeView()); - - const fetchMock = vi.fn(); - fetchMock.mockResolvedValueOnce( - jsonResponse(409, { error: { code: 'stale_revision', message: 'stale' } }), - ); - fetchMock.mockResolvedValueOnce(jsonResponse(200, makeView({ revision: 2 }))); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { ...makeView({ revision: 3 }), changed: true })); - fetchMock.mockResolvedValueOnce(jsonResponse(200, { entries: [], next: 0 })); - vi.stubGlobal('fetch', fetchMock); - - render(SegmentationTab); - const [firstHandle] = screen.getAllByTestId('threshold-handle'); - await fireEvent.input(firstHandle, { target: { value: '90' } }); - await fireEvent.change(firstHandle, { target: { value: '90' } }); - - await waitFor(() => expect(fetchMock).toHaveBeenCalledTimes(4)); - - const [firstUrl, firstInit] = fetchMock.mock.calls[0] as [string, RequestInit]; - expect(firstUrl).toBe('/api/v1/projects/appstate-test/thresholds'); - expect(firstInit.method).toBe('PUT'); - expect(JSON.parse(firstInit.body as string)).toMatchObject({ baseRevision: 1, values: [90, 170] }); - - const [secondUrl] = fetchMock.mock.calls[1] as [string, RequestInit]; - expect(secondUrl).toBe('/api/v1/projects/appstate-test'); - - const [thirdUrl, thirdInit] = fetchMock.mock.calls[2] as [string, RequestInit]; - expect(thirdUrl).toBe('/api/v1/projects/appstate-test/thresholds'); - expect(JSON.parse(thirdInit.body as string)).toMatchObject({ baseRevision: 2, values: [90, 170] }); - }); - - describe('Actions panel enablement', () => { - // Matches Dash exactly: webui.py never disables Create/Add/Remove/Balance - // based on selection/mask state -- only "is a project loaded" and "is - // nothing else in flight" gate them, same as Generate. Copy/Paste/Delete - // moved to LayerPanel's header (see layers/LayerPanel.test.ts). - // Preconditions are enforced by workflow.ts at click time. - const actionTestIds = ['balance-slices', 'create-slice', 'add-mask-to-slice', 'remove-mask-from-slice']; - - it('disables every action button when there is no project', () => { - render(SegmentationTab); - for (const testId of actionTestIds) { - expect(screen.getByTestId(testId)).toBeDisabled(); - } - }); - - it('enables every action button with a project loaded, regardless of selection or mask state', () => { - projectStore.applyView( - makeView({ selectedSlice: null, segmentation: { multiPointMode: false, queuedPoints: [], hasMask: false } }), - ); - render(SegmentationTab); - for (const testId of actionTestIds) { - expect(screen.getByTestId(testId)).toBeEnabled(); - } - }); - - it('disables every action button while a job is in flight', async () => { - projectStore.applyView(makeView()); - render(SegmentationTab); - jobStore.begin('slice-editing'); - await waitFor(() => { - for (const testId of actionTestIds) { - expect(screen.getByTestId(testId)).toBeDisabled(); - } - }); - }); - }); - - describe('ground plane', () => { - it('marks the selected slice as the ground and fits it', async () => { - const ground = { ...makeSlice(1, 170), isGround: true }; - projectStore.applyView( - makeView({ slices: [makeSlice(0, 85), makeSlice(1, 170)], selectedSlice: 1 }), - ); - const fetchMock = vi.fn(async (input: RequestInfo | URL, init?: RequestInit): Promise => { - const url = String(input); - if (url === '/api/v1/projects/appstate-test/slices/1/ground') { - return jsonResponse(200, { - ...makeView({ slices: [makeSlice(0, 85), ground], selectedSlice: 1 }), - changed: true, - }); - } - if (url === '/api/v1/projects/appstate-test/ground/fit') { - return jsonResponse(200, { ...makeView({ slices: [makeSlice(0, 85), ground] }), changed: true }); - } - if (url.startsWith('/api/v1/projects/appstate-test/logs')) return jsonResponse(200, { entries: [], next: 0 }); - throw new Error(`Unexpected fetch: ${init?.method ?? 'GET'} ${url}`); - }); - vi.stubGlobal('fetch', fetchMock); - render(SegmentationTab); - - expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'false'); - expect(screen.getByTestId('ground-fit')).toBeDisabled(); - await fireEvent.click(screen.getByTestId('ground-toggle')); - - await waitFor(() => expect(screen.getByTestId('ground-toggle')).toHaveAttribute('aria-pressed', 'true')); - const call = fetchMock.mock.calls.find(([url]) => String(url).endsWith('/slices/1/ground')); - expect(JSON.parse(call![1]!.body as string)).toEqual({ isGround: true }); - - await waitFor(() => expect(screen.getByTestId('ground-fit')).toBeEnabled()); - await fireEvent.click(screen.getByTestId('ground-fit')); - await waitFor(() => - expect(fetchMock).toHaveBeenCalledWith( - '/api/v1/projects/appstate-test/ground/fit', - expect.objectContaining({ method: 'POST' }), - ), - ); - }); - - it('needs a selected slice to toggle', () => { - projectStore.applyView(makeView({ slices: [makeSlice(0, 85)] })); - render(SegmentationTab); - expect(screen.getByTestId('ground-toggle')).toBeDisabled(); - }); - }); -}); diff --git a/frontend/src/lib/components/shell/Inspector.svelte b/frontend/src/lib/components/shell/Inspector.svelte index 6d9674c..40f870b 100644 --- a/frontend/src/lib/components/shell/Inspector.svelte +++ b/frontend/src/lib/components/shell/Inspector.svelte @@ -8,7 +8,7 @@ */ import { uiStore, WORKFLOW_STEPS, type MainTab } from '../../state/ui.svelte'; import ModeTab from '../depth/ModeTab.svelte'; - import SegmentationTab from '../segmentation/SegmentationTab.svelte'; + import SegmentPanel from '../segmentation/SegmentPanel.svelte'; import InpaintingTab from '../inpainting/InpaintingTab.svelte'; import ExportTab from '../export/ExportTab.svelte'; import ConfigurationTab from '../config/ConfigurationTab.svelte'; @@ -30,7 +30,7 @@
{#if uiStore.mainTab !== 'Configuration'}{/if}
-
+
diff --git a/parallax_maker/api/projects.py b/parallax_maker/api/projects.py index a80b9d3..faab484 100644 --- a/parallax_maker/api/projects.py +++ b/parallax_maker/api/projects.py @@ -203,6 +203,7 @@ def _build_project_view( positive_prompt=image_slice.positive_prompt, negative_prompt=image_slice.negative_prompt, is_ground=image_slice.is_ground_plane, + is_rest=image_slice.is_rest, image=_asset_ref( project_id, f"slice-{index}", file_version(Path(image_slice.filename)) ), diff --git a/parallax_maker/api/schemas.py b/parallax_maker/api/schemas.py index 0ef4259..97a914e 100644 --- a/parallax_maker/api/schemas.py +++ b/parallax_maker/api/schemas.py @@ -58,6 +58,9 @@ class SliceView(ApiModel): mask: AssetRef | None = None #: This slice is the horizontal ground plane rather than a vertical card. is_ground: bool = False + #: This slice holds everything no object slice covers; kept in sync as + #: objects are cut, removed or changed. At most one slice is the rest. + is_rest: bool = False class BusyView(ApiModel): diff --git a/parallax_maker/api/slice_editing.py b/parallax_maker/api/slice_editing.py index 3a697f8..09fec6d 100644 --- a/parallax_maker/api/slice_editing.py +++ b/parallax_maker/api/slice_editing.py @@ -16,6 +16,7 @@ from __future__ import annotations import io +from pathlib import Path from typing import TYPE_CHECKING from flask import Blueprint, request @@ -91,6 +92,11 @@ def create_slice(project_id: str): if result.empty else "Created a slice from the mask" ) + if result.rest_slice_filename is not None: + record.log.append( + f"Created {Path(result.rest_slice_filename).stem} with " + "the rest of the image" + ) record.bump_revision() view = _build_project_view(runtime, project_id, state) diff --git a/parallax_maker/controller.py b/parallax_maker/controller.py index a69d5e7..2b0c151 100644 --- a/parallax_maker/controller.py +++ b/parallax_maker/controller.py @@ -594,6 +594,12 @@ def to_json(self): if ground_index is not None: data["ground_plane_slice"] = ground_index + rest_index = next( + (i for i, s in enumerate(self.image_slices) if s.is_rest), None + ) + if rest_index is not None: + data["rest_slice"] = rest_index + # merge the camera data camera_dict = self._camera.to_json() data.update(camera_dict) @@ -670,6 +676,10 @@ def from_json(json_data): if ground_index is not None and 0 <= ground_index < len(state.image_slices): state.image_slices[ground_index].is_ground_plane = True + rest_index = data.get("rest_slice") + if rest_index is not None and 0 <= rest_index < len(state.image_slices): + state.image_slices[rest_index].is_rest = True + state.server_address = ( data["server_address"] if "server_address" in data else None ) diff --git a/parallax_maker/e2e_server.py b/parallax_maker/e2e_server.py index 502b60b..b84361d 100644 --- a/parallax_maker/e2e_server.py +++ b/parallax_maker/e2e_server.py @@ -208,6 +208,10 @@ def e2e_state(): (i for i, s in enumerate(state.image_slices) if s.is_ground_plane), None, ), + "rest_slice": next( + (i for i, s in enumerate(state.image_slices) if s.is_rest), + None, + ), "mesh_displacement": state.mesh_displacement, } ) diff --git a/parallax_maker/slice.py b/parallax_maker/slice.py index d611d5f..f224932 100644 --- a/parallax_maker/slice.py +++ b/parallax_maker/slice.py @@ -14,6 +14,7 @@ class ImageSlice: "_depth", "_filename", "_is_ground_plane", + "_is_rest", "positive_prompt", "negative_prompt", ) @@ -30,6 +31,7 @@ def __init__( self._depth = depth self._filename = filename self._is_ground_plane = False + self._is_rest = False self.positive_prompt = positive_prompt self.negative_prompt = negative_prompt @@ -63,6 +65,16 @@ def is_ground_plane(self, value): raise ValueError("is_ground_plane must be a boolean value") self._is_ground_plane = value + @property + def is_rest(self): + return self._is_rest + + @is_rest.setter + def is_rest(self, value): + if not isinstance(value, bool): + raise ValueError("is_rest must be a boolean value") + self._is_rest = value + @property def depth(self): return self._depth diff --git a/parallax_maker/slice_editing_services.py b/parallax_maker/slice_editing_services.py index d34e2a4..42936bf 100644 --- a/parallax_maker/slice_editing_services.py +++ b/parallax_maker/slice_editing_services.py @@ -104,6 +104,10 @@ class CreatedSliceResult: slice_index: int empty: bool preview_image: Image.Image | None + #: Set to the new "rest of image" slice's filename when this call created + #: one (the first object slice cut from a project with none yet); ``None`` + #: otherwise, including when an existing rest slice was only updated. + rest_slice_filename: str | None = None @dataclass(frozen=True) @@ -311,10 +315,32 @@ def create_slice(self, command: CreateSlice) -> CreatedSliceResult: source, state.slice_mask, num_expand=self._mask_expand ) + # Snapshot the "rest of image" state before inserting the new object + # slice: whether one already exists, and whether there were any + # slices at all (the trigger for creating one for the first time). + had_slices_before = len(state.image_slices) > 0 + existing_rest = ( + None if empty else next((s for s in state.image_slices if s.is_rest), None) + ) + image_slice = ImageSlice(image, depth) index = state.add_slice(image_slice) state.selected_slice = index image_slice.save_image() + + rest_slice_filename = None + if not empty: + if existing_rest is not None: + self._subtract_from_rest(existing_rest, image_slice) + elif not had_slices_before: + rest_slice = self._create_rest_slice(state, source, image_slice) + rest_slice_filename = str(rest_slice.filename) + # The rest slice sorts by depth (0) alongside the object + # slice, so the object's index may have shifted; keep the + # selection on the object slice itself. + index = state.image_slices.index(image_slice) + state.selected_slice = index + self._states.save(command.state_id, state, self.JSON_ONLY) preview = self._refresh_selection(state) @@ -323,15 +349,109 @@ def create_slice(self, command: CreateSlice) -> CreatedSliceResult: slice_index=index, empty=empty, preview_image=preview, + rest_slice_filename=rest_slice_filename, ) + @staticmethod + def _create_rest_slice( + state: AppState, source: Image.Image, object_slice: ImageSlice + ) -> ImageSlice: + """Creates the farthest "rest of image" slice for the first object cut + from a project with no slices yet: the input image, with alpha equal + to 255 minus the object slice's alpha (so the two exactly cover the + image between them).""" + rest_image = np.array(source.convert("RGBA")) + object_alpha = object_slice.image[:, :, 3].astype(np.int16) + rest_image[:, :, 3] = np.clip(255 - object_alpha, 0, 255).astype(np.uint8) + + rest_slice = ImageSlice(rest_image, depth=0) + rest_slice.is_rest = True + state.add_slice(rest_slice) + rest_slice.save_image() + # Log messages are the UI adapter's concern (see the module + # docstring); the API route logs this from `rest_slice_filename`. + return rest_slice + + @staticmethod + def _find_rest_slice(state: AppState, *, exclude: ImageSlice) -> ImageSlice | None: + """The project's "rest of image" slice, if any - never the slice + being edited itself (syncing the rest slice against its own edits + makes no sense and it never carries the flag with itself excluded).""" + return next( + (s for s in state.image_slices if s.is_rest and s is not exclude), None + ) + + @staticmethod + def _subtract_from_rest(rest_slice: ImageSlice, object_slice: ImageSlice) -> None: + """Removes an object slice's (possibly grown) footprint from the rest + slice's alpha, saving a new version so the edit can be undone. RGB is + left untouched - it no longer shows through once alpha is zero, and + leaving it alone keeps any inpainting elsewhere on the slice intact.""" + rest_alpha = rest_slice.image[:, :, 3].astype(np.int16) + object_alpha = object_slice.image[:, :, 3].astype(np.int16) + new_alpha = np.clip(np.minimum(rest_alpha, 255 - object_alpha), 0, 255).astype( + np.uint8 + ) + rest_slice.image[:, :, 3] = new_alpha + rest_slice.new_version() + + @staticmethod + def _restore_uncovered_into_rest( + rest_slice: ImageSlice, + source_rgb: np.ndarray, + exposed_mask: np.ndarray, + remaining_alpha: np.ndarray, + other_slices, + ) -> bool: + """Gives back to the rest slice what an edit took off an object slice: + wherever ``exposed_mask`` is set and no slice in ``other_slices`` + still covers the pixel, the rest alpha rises to ``255 - + remaining_alpha`` (the object's alpha after the edit), so a partial + (feathered) removal restores only the part it removed. Alpha only + ever rises, and RGB is filled from the input only where the rest + slice was fully transparent - every visible rest pixel, including + any inpainting, is left alone. Returns whether anything changed (and, + if so, saves a new version).""" + covered = np.zeros(exposed_mask.shape, dtype=bool) + for other in other_slices: + covered |= other.image[:, :, 3] > 0 + rest_alpha = rest_slice.image[:, :, 3].astype(np.int16) + target_alpha = 255 - remaining_alpha.astype(np.int16) + raised = exposed_mask & ~covered & (target_alpha > rest_alpha) + if not np.any(raised): + return False + + refill = raised & (rest_alpha == 0) + rest_slice.image[refill, 0:3] = source_rgb[refill] + rest_slice.image[raised, 3] = target_alpha[raised].astype(np.uint8) + rest_slice.new_version() + return True + def delete_slice(self, command: DeleteSlice) -> DeletedSliceResult: state = self._states.load(command.state_id) index = self._slice_index(state, command.slice_index) source = self._require_image(state) + deleted_slice = state.image_slices[index] + rest_slice = self._find_rest_slice(state, exclude=deleted_slice) + if not state.delete_slice(index): raise InvalidSliceIndex(f"slice index {index} is invalid") + + if rest_slice is not None: + # Only pixels the deleted slice actually covered, and that no + # remaining (non-rest) slice covers either, come back; every + # other rest pixel - including any inpainting - is untouched. + exposed = deleted_slice.image[:, :, 3] > 0 + other_slices = [s for s in state.image_slices if s is not rest_slice] + self._restore_uncovered_into_rest( + rest_slice, + np.array(source.convert("RGB")), + exposed, + np.zeros(exposed.shape, dtype=np.uint8), + other_slices, + ) + self._states.save(command.state_id, state, self.JSON_ONLY) # Mirrors delete_slice_request's own direct IMAGE.src output @@ -347,9 +467,16 @@ def add_mask_to_slice(self, command: AddMaskToSlice) -> AddedMaskToSliceResult: mask = self._require_mask(state) source = self._require_image(state) + object_slice = state.image_slices[index] + rest_slice = self._find_rest_slice(state, exclude=object_slice) + merge_image = create_slice_from_mask(source, mask, num_expand=self._mask_expand) - blend_with_alpha(state.image_slices[index].image, merge_image) - filename = state.image_slices[index].new_version() + blend_with_alpha(object_slice.image, merge_image) + filename = object_slice.new_version() + + if rest_slice is not None: + self._subtract_from_rest(rest_slice, object_slice) + self._states.save(command.state_id, state, self.JSON_ONLY) preview = self._refresh_selection(state) @@ -367,9 +494,33 @@ def remove_mask_from_slice( index = self._selected_slice(state) mask = self._require_mask(state) - final_mask = remove_mask_from_alpha(state.image_slices[index].image, mask) - state.image_slices[index].image[:, :, 3] = final_mask - filename = state.image_slices[index].new_version() + object_slice = state.image_slices[index] + rest_slice = self._find_rest_slice(state, exclude=object_slice) + + before_alpha = object_slice.image[:, :, 3].copy() + final_mask = remove_mask_from_alpha(object_slice.image, mask) + object_slice.image[:, :, 3] = final_mask + filename = object_slice.new_version() + + if rest_slice is not None: + source = self._require_image(state) + # Where the mask actually lowered the object's alpha (only + # partly, for a feathered mask); restored into the rest slice + # only where nothing else still covers it. + exposed = final_mask < before_alpha + other_slices = [ + s + for s in state.image_slices + if s is not object_slice and s is not rest_slice + ] + self._restore_uncovered_into_rest( + rest_slice, + np.array(source.convert("RGB")), + exposed, + final_mask, + other_slices, + ) + self._states.save(command.state_id, state, self.JSON_ONLY) preview = self._refresh_selection(state) diff --git a/parallax_maker/test_api_slice_editing.py b/parallax_maker/test_api_slice_editing.py index 5ba7b87..98effa2 100644 --- a/parallax_maker/test_api_slice_editing.py +++ b/parallax_maker/test_api_slice_editing.py @@ -85,6 +85,44 @@ def test_create_slice_with_no_mask_is_empty_at_default_depth(client) -> None: assert body["slices"][new_index]["depth"] == 127 +def test_create_slice_first_cut_creates_a_rest_slice_and_logs_it(client) -> None: + """On a project with no slices yet, the first mask-based create also + creates the farthest "rest of image" slice (SliceView.isRest), and logs + it - mirrors ``e2e_server``'s oracle exposing the same flag.""" + view = upload_fixture_image(client) + project_id = view["id"] + assert view["slices"] == [] + + depth_job = poll_job( + client, + client.post( + f"/api/v1/projects/{project_id}/depth", json={"model": "midas"} + ).get_json()["job"]["id"], + ) + assert depth_job["status"] == "succeeded" + + job = _click(client, project_id, 80, 96, mode="instance") + assert job["status"] == "succeeded" + + response = client.post(f"/api/v1/projects/{project_id}/slices/create", json={}) + + assert response.status_code == 200 + body = response.get_json() + slices = body["slices"] + assert len(slices) == 2 + + rest_slices = [s for s in slices if s["isRest"]] + object_slices = [s for s in slices if not s["isRest"]] + assert len(rest_slices) == 1 + assert len(object_slices) == 1 + # Selection follows the object slice, not the rest slice. + assert body["selectedSlice"] == slices.index(object_slices[0]) + + logs = client.get(f"/api/v1/projects/{project_id}/logs").get_json() + messages = [entry["message"] for entry in logs["entries"]] + assert any("with the rest of the image" in message for message in messages) + + def test_create_slice_requires_an_uploaded_image(client) -> None: view = upload_fixture_image(client) project_id = view["id"] diff --git a/parallax_maker/test_controller.py b/parallax_maker/test_controller.py index 7445473..63fcf88 100644 --- a/parallax_maker/test_controller.py +++ b/parallax_maker/test_controller.py @@ -1,3 +1,4 @@ +import json import numpy as np import unittest from unittest.mock import mock_open, patch, MagicMock @@ -675,5 +676,39 @@ def test_five_slices_are_evenly_distributed_in_order(self): ) +class TestRestSlicePersistence(unittest.TestCase): + """The "rest of image" flag is persisted the same way ``is_ground_plane`` + is: a ``rest_slice`` index written only when present, restored on load, + and simply absent (never inferred) for state files predating it.""" + + def test_rest_flag_survives_a_json_round_trip(self): + state = AppState() + state.filename = str(Path.cwd() / "appstate-rest-test") + state.imgThresholds = [0, 128, 255] + state.image_slices = [ + ImageSlice(depth=0, filename=f"{state.filename}/image_slice_0.png"), + ImageSlice(depth=128, filename=f"{state.filename}/image_slice_1.png"), + ] + state.image_slices[0].is_rest = True + + restored = AppState.from_json(state.to_json()) + self.assertEqual([s.is_rest for s in restored.image_slices], [True, False]) + + def test_a_state_file_predating_the_flag_loads_with_no_rest_slice(self): + state = AppState() + state.filename = str(Path.cwd() / "appstate-rest-test-legacy") + state.imgThresholds = [0, 128, 255] + state.image_slices = [ + ImageSlice(depth=0, filename=f"{state.filename}/image_slice_0.png"), + ImageSlice(depth=128, filename=f"{state.filename}/image_slice_1.png"), + ] + state.image_slices[0].is_rest = True + + data = json.loads(state.to_json()) + del data["rest_slice"] + legacy = AppState.from_json(json.dumps(data)) + self.assertFalse(any(s.is_rest for s in legacy.image_slices)) + + if __name__ == "__main__": unittest.main() diff --git a/parallax_maker/test_slice_editing_services.py b/parallax_maker/test_slice_editing_services.py index 573f8db..2badf0d 100644 --- a/parallax_maker/test_slice_editing_services.py +++ b/parallax_maker/test_slice_editing_services.py @@ -86,6 +86,29 @@ def make_mask(state: AppState, *, inside=(2, 8, 5, 15), value=255) -> np.ndarray return mask +def make_empty_state(tmp_path: Path) -> AppState: + """A state with an input image but no slices yet - the trigger for + creating a "rest of image" slice on the first mask-based create.""" + state = AppState() + state.filename = str(tmp_path) + state.imgData = Image.new("RGB", (20, 10), (100, 110, 120)) + state.image_slices = [] + return state + + +def make_custom_slice( + tmp_path: Path, name: str, depth: float, alpha: np.ndarray, *, rgb=(10, 20, 30) +) -> ImageSlice: + """Like ``make_slice``, but with an explicit, arbitrary alpha channel.""" + height, width = alpha.shape + image = np.zeros((height, width, 4), dtype=np.uint8) + image[:, :, :3] = rgb + image[:, :, 3] = alpha + filename = tmp_path / f"{name}.png" + Image.fromarray(image, mode="RGBA").save(filename) + return ImageSlice(image.copy(), depth=depth, filename=str(filename)) + + # --- create_slice ------------------------------------------------------------ @@ -536,3 +559,289 @@ def test_set_checkerboard_requires_a_boolean(tmp_path: Path) -> None: with pytest.raises(SliceEditingNotReady): service.set_checkerboard(SetCheckerboard(state_id="s", enabled="yes")) # type: ignore[arg-type] + + +# --- "rest of image" slice: creation and sync ----------------------------------- + + +def test_create_slice_first_cut_also_creates_a_rest_slice(tmp_path: Path) -> None: + state = make_empty_state(tmp_path) + state.slice_mask = make_mask(state) + service, repository = make_service(state) + + result = service.create_slice(CreateSlice(state_id="s")) + + assert len(state.image_slices) == 2 + rest = next(s for s in state.image_slices if s.is_rest) + obj = next(s for s in state.image_slices if not s.is_rest) + assert rest.depth == 0 + assert result.rest_slice_filename == str(rest.filename) + + # Complementary alpha: the object and rest slice exactly cover the image + # between them, everywhere - not just inside the (binary) mask. + total = obj.image[:, :, 3].astype(int) + rest.image[:, :, 3].astype(int) + assert np.all(total == 255) + + # The rest slice's RGB comes from the input image. + source_rgb = np.array(state.imgData.convert("RGB")) + np.testing.assert_array_equal(rest.image[:, :, :3], source_rgb) + + # Selection follows the object slice even though the rest slice (depth 0) + # may have been inserted ahead of it. + obj_index = state.image_slices.index(obj) + assert state.selected_slice == obj_index + assert result.slice_index == obj_index + assert repository.saved == [("s", JSON_ONLY)] + + +def test_create_slice_without_a_mask_never_touches_the_rest_slice( + tmp_path: Path, +) -> None: + state = make_empty_state(tmp_path) + assert state.slice_mask is None + service, _ = make_service(state) + + result = service.create_slice(CreateSlice(state_id="s")) + + assert result.empty is True + assert len(state.image_slices) == 1 + assert result.rest_slice_filename is None + assert not state.image_slices[0].is_rest + + +def test_create_slice_in_a_depth_split_project_does_not_add_a_rest_slice( + tmp_path: Path, +) -> None: + """Slices already exist (as if split by depth), but none is the rest + slice: creating from a mask behaves exactly as it did before this + feature, with no rest slice added.""" + state = make_state(tmp_path) + state.slice_mask = make_mask(state) + service, _ = make_service(state) + + result = service.create_slice(CreateSlice(state_id="s")) + + assert len(state.image_slices) == 3 + assert not any(s.is_rest for s in state.image_slices) + assert result.rest_slice_filename is None + + +def test_create_slice_second_cut_subtracts_from_the_existing_rest_slice( + tmp_path: Path, +) -> None: + state = make_empty_state(tmp_path) + state.slice_mask = make_mask(state, inside=(2, 8, 5, 15)) + service, repository = make_service(state) + + service.create_slice(CreateSlice(state_id="s")) # first cut: creates the rest + rest = next(s for s in state.image_slices if s.is_rest) + rest_filename_before = rest.filename + rest_alpha_before = rest.image[:, :, 3].copy() + + # A second, disjoint cut. + state.slice_mask = make_mask(state, inside=(0, 2, 0, 5)) + repository.saved.clear() + result = service.create_slice(CreateSlice(state_id="s")) + + assert len(state.image_slices) == 3 + assert result.rest_slice_filename is None # not newly created, only updated + rest_after = next(s for s in state.image_slices if s.is_rest) + assert rest_after is rest # same slice, new version + assert rest_after.filename != rest_filename_before + + # The rest slice's alpha only ever decreases, and it strictly decreases + # somewhere (where the new object slice now covers it). + assert np.all(rest_after.image[:, :, 3] <= rest_alpha_before) + assert np.any(rest_after.image[:, :, 3] < rest_alpha_before) + assert repository.saved == [("s", JSON_ONLY)] + + +# --- "rest of image" slice: kept in sync on delete/add-mask/remove-mask -------- + + +def test_delete_slice_restores_only_uncovered_pixels_and_preserves_rest_edits( + tmp_path: Path, +) -> None: + height, width = 10, 20 + state = AppState() + state.filename = str(tmp_path) + source_rgb = np.zeros((height, width, 3), dtype=np.uint8) + source_rgb[:, :, 0] = 111 + source_rgb[:, :, 1] = 222 + source_rgb[:, :, 2] = 33 + state.imgData = Image.fromarray(source_rgb, mode="RGB") + + # Object A (to be deleted) covers rows 0:5, cols 0:10. + alpha_a = np.zeros((height, width), dtype=np.uint8) + alpha_a[0:5, 0:10] = 255 + object_a = make_custom_slice(tmp_path, "image_slice_1", depth=50, alpha=alpha_a) + + # Object B (stays) covers a small patch that overlaps A's region. + alpha_b = np.zeros((height, width), dtype=np.uint8) + alpha_b[3:5, 5:8] = 255 + object_b = make_custom_slice(tmp_path, "image_slice_2", depth=100, alpha=alpha_b) + + # The rest slice covers everything neither A nor B does. + rest_alpha = np.where((alpha_a > 0) | (alpha_b > 0), 0, 255).astype(np.uint8) + rest_image = np.zeros((height, width, 4), dtype=np.uint8) + rest_image[:, :, :3] = source_rgb + rest_image[:, :, 3] = rest_alpha + # Simulate prior inpainting on the rest slice: one pixel inside the area + # that will be uncovered by the delete, one pixel well outside it. + rest_image[1, 2] = (9, 9, 9, 222) + rest_image[6, 1] = (7, 7, 7, 255) + rest_filename = tmp_path / "image_slice_0.png" + Image.fromarray(rest_image, mode="RGBA").save(rest_filename) + rest_slice = ImageSlice(rest_image.copy(), depth=0, filename=str(rest_filename)) + rest_slice.is_rest = True + + state.image_slices = [rest_slice, object_a, object_b] + service, repository = make_service(state) + + service.delete_slice(DeleteSlice(state_id="s", slice_index=1)) + + assert state.image_slices == [rest_slice, object_b] + assert rest_slice.filename != str(rest_filename) # a new version was saved + + # (1, 2): inside A, not covered by B - made fully opaque again, keeping + # the partly visible "inpainted" color rather than overwriting it. + assert tuple(int(v) for v in rest_slice.image[1, 2]) == (9, 9, 9, 255) + # (0, 0): inside A, transparent on the rest - refilled from the input. + assert tuple(int(v) for v in rest_slice.image[0, 0]) == (111, 222, 33, 255) + # (4, 6): inside A, but still covered by B - left exactly as it was. + assert tuple(int(v) for v in rest_slice.image[4, 6]) == (111, 222, 33, 0) + # (6, 1): outside A entirely - the "inpainted" pixel survives untouched. + assert tuple(int(v) for v in rest_slice.image[6, 1]) == (7, 7, 7, 255) + assert repository.saved == [("s", JSON_ONLY)] + + +def test_delete_slice_of_the_rest_slice_itself_removes_it( + tmp_path: Path, +) -> None: + state = make_state(tmp_path) + state.image_slices[0].is_rest = True + service, _ = make_service(state) + + service.delete_slice(DeleteSlice(state_id="s", slice_index=0)) + + assert len(state.image_slices) == 1 + assert not any(s.is_rest for s in state.image_slices) + + +def test_add_mask_to_slice_subtracts_the_added_area_from_the_rest( + tmp_path: Path, +) -> None: + height, width = 10, 20 + state = AppState() + state.filename = str(tmp_path) + state.imgData = Image.new("RGB", (width, height), (100, 110, 120)) + + # The object already covers a small patch; the rest covers everything else. + alpha_object = np.zeros((height, width), dtype=np.uint8) + alpha_object[0:2, 0:4] = 255 + object_slice = make_custom_slice(tmp_path, "image_slice_1", depth=50, alpha=alpha_object) + + rest_alpha = np.where(alpha_object > 0, 0, 255).astype(np.uint8) + rest_slice = make_custom_slice(tmp_path, "image_slice_0", depth=0, alpha=rest_alpha) + rest_slice.is_rest = True + + state.image_slices = [rest_slice, object_slice] + state.selected_slice = 1 + state.slice_mask = make_mask(state, inside=(4, 8, 10, 18)) + service, repository = make_service(state) + + original_rest_filename = rest_slice.filename + service.add_mask_to_slice(AddMaskToSlice(state_id="s")) + + assert rest_slice.filename != original_rest_filename # version bumped + # Where the object grew (the newly-added mask), the rest gives up alpha. + assert rest_slice.image[6, 14, 3] < 255 + # Where the object already covered, the rest is unaffected (still zero). + assert rest_slice.image[0, 0, 3] == 0 + # Far from both (outside the feathered mask's reach too), untouched. + assert rest_slice.image[9, 0, 3] == 255 + assert repository.saved == [("s", JSON_ONLY)] + + +def test_remove_mask_from_slice_restores_only_uncovered_pixels_into_the_rest( + tmp_path: Path, +) -> None: + height, width = 10, 20 + state = AppState() + state.filename = str(tmp_path) + source_rgb = np.full((height, width, 3), (50, 60, 70), dtype=np.uint8) + state.imgData = Image.fromarray(source_rgb, mode="RGB") + + # The object covers rows 2:8, cols 2:18. + alpha_object = np.zeros((height, width), dtype=np.uint8) + alpha_object[2:8, 2:18] = 255 + object_slice = make_custom_slice(tmp_path, "image_slice_1", depth=50, alpha=alpha_object) + + # Another slice keeps covering a sub-patch of the object's area. + alpha_other = np.zeros((height, width), dtype=np.uint8) + alpha_other[4:6, 10:14] = 255 + other_slice = make_custom_slice(tmp_path, "image_slice_2", depth=100, alpha=alpha_other) + + rest_alpha = np.where( + (alpha_object > 0) | (alpha_other > 0), 0, 255 + ).astype(np.uint8) + rest_slice = make_custom_slice( + tmp_path, "image_slice_0", depth=0, alpha=rest_alpha, rgb=(50, 60, 70) + ) + rest_slice.is_rest = True + + state.image_slices = [rest_slice, object_slice, other_slice] + state.selected_slice = 1 + # Remove the mask covering the object's entire region. + state.slice_mask = make_mask(state, inside=(2, 8, 2, 18)) + service, repository = make_service(state) + + original_rest_filename = rest_slice.filename + service.remove_mask_from_slice(RemoveMaskFromSlice(state_id="s")) + + assert rest_slice.filename != original_rest_filename + # Inside the object's region but outside the other slice: restored. + assert tuple(int(v) for v in rest_slice.image[2, 2]) == (50, 60, 70, 255) + # Inside both the object's region and the other slice: left alone. + assert rest_slice.image[5, 12, 3] == 0 + # Entirely outside the object's region: untouched. + assert rest_slice.image[9, 19, 3] == 255 + assert repository.saved == [("s", JSON_ONLY)] + + +def test_remove_mask_from_slice_restores_a_feathered_removal_only_partly( + tmp_path: Path, +) -> None: + height, width = 10, 20 + state = AppState() + state.filename = str(tmp_path) + source_rgb = np.full((height, width, 3), (50, 60, 70), dtype=np.uint8) + state.imgData = Image.fromarray(source_rgb, mode="RGB") + + alpha_object = np.zeros((height, width), dtype=np.uint8) + alpha_object[2:8, 2:18] = 255 + object_slice = make_custom_slice(tmp_path, "image_slice_1", depth=50, alpha=alpha_object) + rest_slice = make_custom_slice( + tmp_path, + "image_slice_0", + depth=0, + alpha=(255 - alpha_object).astype(np.uint8), + rgb=(50, 60, 70), + ) + rest_slice.is_rest = True + + state.image_slices = [rest_slice, object_slice] + state.selected_slice = 1 + # A feathered mask: removes only part of the object's alpha. + mask = np.zeros((height, width), dtype=np.uint8) + mask[2:8, 2:18] = 100 + state.slice_mask = mask + service, _ = make_service(state) + + service.remove_mask_from_slice(RemoveMaskFromSlice(state_id="s")) + + remaining = int(object_slice.image[4, 4, 3]) + assert 0 < remaining < 255 + # The rest slice gets back exactly what the object lost. + assert int(rest_slice.image[4, 4, 3]) == 255 - remaining + assert int(rest_slice.image[0, 0, 3]) == 255