feat: curseur tolérance verre pour récupérer les zones semi-transparentes du détourage

Ajoute alphaThreshold (défaut 30) — les pixels dont l'alpha dépasse ce seuil
sont rendus opaques avant la composition sur fond blanc, évitant que le verre
ou les surfaces translucides soient supprimés par le modèle IA.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-19 11:28:42 +02:00
parent c1fad5d0ed
commit d45c9490c0
5 changed files with 53 additions and 14 deletions
+2 -1
View File
@@ -10,7 +10,7 @@ import { useImages } from '@/hooks/useImages'
import type { ImageItem } from '@/types/images' import type { ImageItem } from '@/types/images'
function ImagesPageInner() { function ImagesPageInner() {
const { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages() const { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct } = useImages()
const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null) const [lightboxItem, setLightboxItem] = useState<ImageItem | null>(null)
return ( return (
@@ -38,6 +38,7 @@ function ImagesPageInner() {
key={item.id} key={item.id}
item={item} item={item}
onFeatherChange={setFeather} onFeatherChange={setFeather}
onAlphaThresholdChange={setAlphaThreshold}
onRotate={rotate} onRotate={rotate}
onUpload={uploadOne} onUpload={uploadOne}
onRemove={removeItem} onRemove={removeItem}
+16 -1
View File
@@ -5,6 +5,7 @@ import type { ImageItem } from '@/types/images'
interface ImageCardProps { interface ImageCardProps {
item: ImageItem item: ImageItem
onFeatherChange: (id: string, value: number) => void onFeatherChange: (id: string, value: number) => void
onAlphaThresholdChange: (id: string, value: number) => void
onRotate: (id: string, direction: 'cw' | 'ccw') => void onRotate: (id: string, direction: 'cw' | 'ccw') => void
onUpload: (id: string) => void onUpload: (id: string) => void
onRemove: (id: string) => void onRemove: (id: string) => void
@@ -94,6 +95,7 @@ function ProductSearch({ itemId, onAssign }: { itemId: string; onAssign: (id: st
export function ImageCard({ export function ImageCard({
item, item,
onFeatherChange, onFeatherChange,
onAlphaThresholdChange,
onRotate, onRotate,
onUpload, onUpload,
onRemove, onRemove,
@@ -148,7 +150,7 @@ export function ImageCard({
<ProductSearch itemId={item.id} onAssign={onAssignProduct} /> <ProductSearch itemId={item.id} onAssign={onAssignProduct} />
)} )}
{/* Contrôles feather + rotation */} {/* Contrôles feather + tolérance + rotation */}
<div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}> <div className={canAdjust ? '' : 'opacity-40 pointer-events-none'}>
<label className="block text-xs text-slate-500 mb-1"> <label className="block text-xs text-slate-500 mb-1">
Bords : {item.feather} px Bords : {item.feather} px
@@ -163,6 +165,19 @@ export function ImageCard({
className="w-full accent-indigo-500" className="w-full accent-indigo-500"
/> />
<label className="block text-xs text-slate-500 mb-1 mt-2">
Tolérance verre : {item.alphaThreshold}
</label>
<input
type="range"
min={0}
max={100}
step={5}
value={item.alphaThreshold}
onChange={(e) => onAlphaThresholdChange(item.id, Number(e.target.value))}
className="w-full accent-amber-500"
/>
<div className="flex gap-1 mt-2"> <div className="flex gap-1 mt-2">
<button <button
onClick={() => onRotate(item.id, 'ccw')} onClick={() => onRotate(item.id, 'ccw')}
+22 -11
View File
@@ -90,7 +90,7 @@ export function useImages() {
// Pipeline complet pour un item (avec ref) // Pipeline complet pour un item (avec ref)
const runPipeline = useCallback( const runPipeline = useCallback(
async (id: string, blob: Blob, ref: string, feather: number, rotation: number) => { async (id: string, blob: Blob, ref: string, feather: number, rotation: number, alphaThreshold: number) => {
const { convertHeicToJpeg } = await import('@/lib/client/heicConverter') const { convertHeicToJpeg } = await import('@/lib/client/heicConverter')
const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover') const { removeBackgroundRaw, compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator') const { rotateBlob } = await import('@/lib/client/imageRotator')
@@ -120,7 +120,7 @@ export function useImages() {
let finalBlob: Blob let finalBlob: Blob
try { try {
const composited = await compositeOnWhite(rawPng, feather) const composited = await compositeOnWhite(rawPng, feather, alphaThreshold)
finalBlob = await rotateBlob(composited, rotation) finalBlob = await rotateBlob(composited, rotation)
} catch (err) { } catch (err) {
update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` }) update(id, { status: 'error', error: `Post-traitement : ${(err as Error).message}` })
@@ -133,15 +133,15 @@ export function useImages() {
[update, searchProduct], [update, searchProduct],
) )
// Recomposite rapide (feather ou rotation changés) — ne relance pas l'IA // Recomposite rapide (feather, alphaThreshold ou rotation changés) — ne relance pas l'IA
const recomposite = useCallback( const recomposite = useCallback(
async (id: string, rawPngBlob: Blob, feather: number, rotation: number) => { async (id: string, rawPngBlob: Blob, feather: number, rotation: number, alphaThreshold: number) => {
const { compositeOnWhite } = await import('@/lib/client/bgRemover') const { compositeOnWhite } = await import('@/lib/client/bgRemover')
const { rotateBlob } = await import('@/lib/client/imageRotator') const { rotateBlob } = await import('@/lib/client/imageRotator')
try { try {
const composited = await compositeOnWhite(rawPngBlob, feather) const composited = await compositeOnWhite(rawPngBlob, feather, alphaThreshold)
const finalBlob = await rotateBlob(composited, rotation) const finalBlob = await rotateBlob(composited, rotation)
update(id, { processedBlob: finalBlob, feather, rotation }) update(id, { processedBlob: finalBlob, feather, rotation, alphaThreshold })
} catch (err) { } catch (err) {
update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` }) update(id, { status: 'error', error: `Retraitement : ${(err as Error).message}` })
} }
@@ -167,6 +167,7 @@ export function useImages() {
processedBlob: null, processedBlob: null,
rotation: 0, rotation: 0,
feather: 2, feather: 2,
alphaThreshold: 30,
shopifyProductId: null, shopifyProductId: null,
shopifyProductTitle: null, shopifyProductTitle: null,
uploadedUrl: null, uploadedUrl: null,
@@ -176,7 +177,7 @@ export function useImages() {
})) }))
dispatch({ type: 'ADD', items: newItems }) dispatch({ type: 'ADD', items: newItems })
for (const item of newItems) { for (const item of newItems) {
runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation) runPipeline(item.id, item.originalBlob, item.ref, item.feather, item.rotation, item.alphaThreshold)
} }
} else { } else {
const ref = refFromFilename(file.name) const ref = refFromFilename(file.name)
@@ -189,6 +190,7 @@ export function useImages() {
processedBlob: null, processedBlob: null,
rotation: 0, rotation: 0,
feather: 2, feather: 2,
alphaThreshold: 30,
shopifyProductId: null, shopifyProductId: null,
shopifyProductTitle: null, shopifyProductTitle: null,
uploadedUrl: null, uploadedUrl: null,
@@ -197,7 +199,7 @@ export function useImages() {
progress: null, progress: null,
} }
dispatch({ type: 'ADD', items: [newItem] }) dispatch({ type: 'ADD', items: [newItem] })
runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation) runPipeline(newItem.id, newItem.originalBlob, newItem.ref, newItem.feather, newItem.rotation, newItem.alphaThreshold)
} }
} }
}, },
@@ -208,7 +210,16 @@ export function useImages() {
(id: string, value: number) => { (id: string, value: number) => {
const item = items.find((i) => i.id === id) const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, value, item.rotation) recomposite(id, item.rawPngBlob, value, item.rotation, item.alphaThreshold)
},
[items, recomposite],
)
const setAlphaThreshold = useCallback(
(id: string, value: number) => {
const item = items.find((i) => i.id === id)
if (!item || !item.rawPngBlob) return
recomposite(id, item.rawPngBlob, item.feather, item.rotation, value)
}, },
[items, recomposite], [items, recomposite],
) )
@@ -219,7 +230,7 @@ export function useImages() {
if (!item || !item.rawPngBlob) return if (!item || !item.rawPngBlob) return
const delta = direction === 'cw' ? 90 : -90 const delta = direction === 'cw' ? 90 : -90
const newRotation = ((item.rotation + delta) % 360 + 360) % 360 const newRotation = ((item.rotation + delta) % 360 + 360) % 360
recomposite(id, item.rawPngBlob, item.feather, newRotation) recomposite(id, item.rawPngBlob, item.feather, newRotation, item.alphaThreshold)
}, },
[items, recomposite], [items, recomposite],
) )
@@ -269,5 +280,5 @@ export function useImages() {
[update], [update],
) )
return { items, addFiles, setFeather, rotate, uploadOne, uploadAll, removeItem, assignProduct } return { items, addFiles, setFeather, setAlphaThreshold, rotate, uploadOne, uploadAll, removeItem, assignProduct }
} }
+12 -1
View File
@@ -38,8 +38,9 @@ export async function removeBackgroundRaw(
* Composite un PNG transparent sur fond blanc avec feather optionnel. * Composite un PNG transparent sur fond blanc avec feather optionnel.
* @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw) * @param pngBlob — PNG avec transparence (résultat de removeBackgroundRaw)
* @param feather — récupération des bords en px (0 = strict, 5 = doux) * @param feather — récupération des bords en px (0 = strict, 5 = doux)
* @param alphaThreshold — pixels avec alpha > seuil deviennent opaques (0 = désactivé, ex: 30 pour récupérer le verre)
*/ */
export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob> { export function compositeOnWhite(pngBlob: Blob, feather: number, alphaThreshold = 0): Promise<Blob> {
const objectUrl = URL.createObjectURL(pngBlob) const objectUrl = URL.createObjectURL(pngBlob)
return new Promise((resolve, reject) => { return new Promise((resolve, reject) => {
@@ -55,6 +56,16 @@ export function compositeOnWhite(pngBlob: Blob, feather: number): Promise<Blob>
fgCtx.drawImage(img, 0, 0) fgCtx.drawImage(img, 0, 0)
// Remonte les pixels semi-transparents (ex: verre) à pleine opacité
if (alphaThreshold > 0) {
const imageData = fgCtx.getImageData(0, 0, width, height)
const data = imageData.data
for (let i = 3; i < data.length; i += 4) {
if (data[i] > alphaThreshold) data[i] = 255
}
fgCtx.putImageData(imageData, 0, 0)
}
if (feather > 0) { if (feather > 0) {
fgCtx.globalCompositeOperation = 'destination-over' fgCtx.globalCompositeOperation = 'destination-over'
fgCtx.filter = `blur(${feather}px)` fgCtx.filter = `blur(${feather}px)`
+1
View File
@@ -24,6 +24,7 @@ export interface ImageItem {
processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après") processedBlob: Blob | null // JPEG final sur fond blanc, pivoté (pour upload + vue "après")
rotation: number // 0, 90, 180, 270 rotation: number // 0, 90, 180, 270
feather: number // 05, défaut 2 feather: number // 05, défaut 2
alphaThreshold: number // 0100, défaut 30 — pixels semi-transparents rendus opaques
shopifyProductId: string | null shopifyProductId: string | null
shopifyProductTitle: string | null shopifyProductTitle: string | null
uploadedUrl: string | null uploadedUrl: string | null